diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 977bd6b5d21..039e6380e00 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -904,12 +904,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)
@@ -938,7 +958,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/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json
index 071a249e01c..953eb8a21fc 100644
--- a/config/scripts/locale-ko-key-overrides.json
+++ b/config/scripts/locale-ko-key-overrides.json
@@ -1559,12 +1559,6 @@
"auto.components.onboarding.ThemeStep.94b9dc561d": {
"ko": "설정 -> Terminal"
},
- "auto.components.pet.PetOverlay.4712d196c6": {
- "ko": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }"
- },
- "auto.components.pet.PetOverlay.de932b0e8f": {
- "ko": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }"
- },
"auto.components.repo.NestedRepoChecklist.ea54c7bf8f": {
"ko": "/"
},
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index ff7c9fd7520..f5d3d95b9d0 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
- downloads: 3.4m
+
+ downloads: 3.5m
@@ -15,7 +15,7 @@
downloads
downloads
- 3.4m
- 3.4m
+ 3.5m
+ 3.5m
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/package.json b/package.json
index fbf46f4735a..47cdc95c5b4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.122-rc.2",
+ "version": "1.4.123-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
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/automations/service.test.ts b/src/main/automations/service.test.ts
index 9ab1ed45d57..786ff017bb2 100644
--- a/src/main/automations/service.test.ts
+++ b/src/main/automations/service.test.ts
@@ -348,6 +348,70 @@ describe('AutomationService', () => {
)
})
+ it('dispatches due scheduled automations headlessly', async () => {
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+ const beforeRunAt = new Date(2026, 4, 13, 8, 59).getTime()
+ const scheduledRunAt = new Date(2026, 4, 13, 9, 0).getTime()
+ const afterRunAt = new Date(2026, 4, 13, 9, 1).getTime()
+ const nextRunAt = new Date(2026, 4, 14, 9, 0).getTime()
+
+ vi.setSystemTime(beforeRunAt)
+ const store = await createStore()
+ const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
+ store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
+ const setup = store.getProjectHostSetups()[0]!
+ const automation = store.createAutomation({
+ name: 'Morning check',
+ prompt: 'Check the repo',
+ agentId: 'claude',
+ projectId: 'r1',
+ runContext: {
+ kind: 'workspace-run',
+ projectId: setup.projectId,
+ hostId: runtimeHostId,
+ projectHostSetupId: setup.id,
+ repoId: setup.repoId,
+ path: setup.path
+ },
+ workspaceMode: 'new_per_run',
+ timezone,
+ rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
+ dtstart: new Date(2026, 4, 12, 0, 0).getTime()
+ })
+
+ const headlessDispatcher = vi.fn().mockResolvedValue({
+ workspaceId: 'wt1',
+ workspaceDisplayName: 'Morning check',
+ terminalSessionId: 'tab-1',
+ terminalPaneKey: 'pane-1',
+ terminalPtyId: 'pty-1'
+ })
+ const service = new AutomationService(store, {
+ tickMs: 60_000,
+ allowRemoteHostScheduling: true,
+ headlessDispatcher
+ })
+
+ try {
+ vi.setSystemTime(afterRunAt)
+ service.start()
+ await vi.waitFor(() => expect(headlessDispatcher).toHaveBeenCalledTimes(1))
+ const run = store.listAutomationRuns(automation.id)[0]
+ expect(run?.status).toBe('dispatched')
+ expect(run?.scheduledFor).toBe(scheduledRunAt)
+ expect(headlessDispatcher).toHaveBeenCalledWith(
+ expect.objectContaining({ automation: expect.objectContaining({ id: automation.id }) })
+ )
+ await vi.waitFor(() =>
+ expect(store.listAutomations().find((entry) => entry.id === automation.id)?.nextRunAt).toBe(
+ nextRunAt
+ )
+ )
+ } finally {
+ service.stop()
+ }
+ })
+
it('attaches provider usage when a completed run can be attributed', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts
index 7a192153d1f..dc22550e7ec 100644
--- a/src/main/automations/service.ts
+++ b/src/main/automations/service.ts
@@ -69,7 +69,9 @@ export class AutomationService {
this.timer = setInterval(() => {
void this.evaluateDueRuns()
}, this.tickMs)
- if (this.rendererReady) {
+ // Why: headless serve never gets a renderer-ready IPC, but due runs still
+ // need the same startup catch-up pass desktop gets after renderer attach.
+ if (this.rendererReady || this.headlessDispatcher) {
void this.evaluateDueRuns()
}
}
diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts
index 28b7a8b2421..76a1b1c7953 100644
--- a/src/main/browser/browser-guest-ui.test.ts
+++ b/src/main/browser/browser-guest-ui.test.ts
@@ -433,6 +433,26 @@ describe('setupGuestShortcutForwarding', () => {
return preventDefault
}
+ function triggerZoomChanged(direction: 'in' | 'out' | 'reset'): ReturnType {
+ const handler = guestOnMock.mock.calls.find((call) => call[0] === 'zoom-changed')?.[1] as
+ | ((event: Electron.Event, direction: 'in' | 'out' | 'reset') => void)
+ | undefined
+ expect(handler).toBeTypeOf('function')
+ const preventDefault = vi.fn()
+ handler!({ preventDefault } as unknown as Electron.Event, direction)
+ return preventDefault
+ }
+
+ function triggerBeforeMouse(mouse: Electron.MouseInputEvent): ReturnType {
+ const handler = guestOnMock.mock.calls.find((call) => call[0] === 'before-mouse-event')?.[1] as
+ | ((event: Electron.Event, mouse: Electron.MouseInputEvent) => void)
+ | undefined
+ expect(handler).toBeTypeOf('function')
+ const preventDefault = vi.fn()
+ handler!({ preventDefault } as unknown as Electron.Event, mouse)
+ return preventDefault
+ }
+
function triggerGuestBlur(): void {
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'blur')?.[1] as
| (() => void)
@@ -488,7 +508,7 @@ describe('setupGuestShortcutForwarding', () => {
}
})
- it('forwards app zoom shortcuts from focused guest pages', () => {
+ it('forwards browser page zoom shortcuts from focused guest pages', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
@@ -516,12 +536,12 @@ describe('setupGuestShortcutForwarding', () => {
expect(numpadSubtractPreventDefault).toHaveBeenCalledTimes(1)
expect(resetPreventDefault).toHaveBeenCalledTimes(1)
expect(repeatPreventDefault).toHaveBeenCalledTimes(1)
- expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'terminal:zoom', 'in')
- expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'terminal:zoom', 'in')
- expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'terminal:zoom', 'out')
- expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'terminal:zoom', 'out')
- expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'terminal:zoom', 'reset')
- expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'terminal:zoom', 'in')
+ expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'in')
+ expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'in')
+ expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:zoomBrowserPage', 'out')
+ expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:zoomBrowserPage', 'out')
+ expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:zoomBrowserPage', 'reset')
+ expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'ui:zoomBrowserPage', 'in')
})
it('forwards browser history shortcuts from focused guest pages', () => {
@@ -600,7 +620,73 @@ describe('setupGuestShortcutForwarding', () => {
expect(defaultPreventDefault).not.toHaveBeenCalled()
expect(customPreventDefault).toHaveBeenCalledTimes(1)
- expect(rendererSendMock).toHaveBeenCalledWith('terminal:zoom', 'in')
+ expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
+ })
+
+ it('forwards native guest zoom commands to browser page zoom when default zoom keys are bound', () => {
+ setupGuestShortcutForwarding({
+ browserTabId,
+ guest: makeGuest(),
+ resolveRenderer: () => makeRenderer()
+ })
+
+ const zoomOutPreventDefault = triggerZoomChanged('out')
+ const zoomInPreventDefault = triggerZoomChanged('in')
+ const resetPreventDefault = triggerZoomChanged('reset')
+
+ expect(zoomOutPreventDefault).toHaveBeenCalledTimes(1)
+ expect(zoomInPreventDefault).toHaveBeenCalledTimes(1)
+ expect(resetPreventDefault).not.toHaveBeenCalled()
+ expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'out')
+ expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'in')
+ })
+
+ it('does not double-forward ctrl wheel when Electron also emits a native zoom command', () => {
+ const guest = makeGuest()
+
+ setupGuestMouseWheelZoomForwarding({
+ browserTabId,
+ guest,
+ resolveRenderer: () => makeRenderer()
+ })
+ setupGuestShortcutForwarding({
+ browserTabId,
+ guest,
+ resolveRenderer: () => makeRenderer()
+ })
+
+ const wheelPreventDefault = triggerBeforeMouse({
+ type: 'mouseWheel',
+ x: 0,
+ y: 0,
+ deltaY: -120,
+ modifiers: ['ctrl']
+ } as Electron.MouseWheelInputEvent)
+ const zoomCommandPreventDefault = triggerZoomChanged('in')
+
+ expect(wheelPreventDefault).toHaveBeenCalledTimes(1)
+ expect(zoomCommandPreventDefault).toHaveBeenCalledTimes(1)
+ expect(rendererSendMock).toHaveBeenCalledTimes(1)
+ expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
+ })
+
+ it('ignores native guest zoom commands when matching default zoom keys are unbound', () => {
+ setupGuestShortcutForwarding({
+ browserTabId,
+ guest: makeGuest(),
+ resolveRenderer: () => makeRenderer(),
+ getKeybindings: () => ({
+ 'zoom.in': ['Mod+Alt+Z'],
+ 'zoom.out': []
+ })
+ })
+
+ const zoomOutPreventDefault = triggerZoomChanged('out')
+ const zoomInPreventDefault = triggerZoomChanged('in')
+
+ expect(zoomOutPreventDefault).not.toHaveBeenCalled()
+ expect(zoomInPreventDefault).not.toHaveBeenCalled()
+ expect(rendererSendMock).not.toHaveBeenCalled()
})
it('forwards double-tap window shortcuts from focused guest pages', () => {
diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts
index 469803cc85f..ecfdae2e62b 100644
--- a/src/main/browser/browser-guest-ui.ts
+++ b/src/main/browser/browser-guest-ui.ts
@@ -11,6 +11,7 @@ import {
import {
isRecentTabSwitcherCommitRelease,
matchesRecentTabSwitcherChord,
+ nativeZoomCommandMatchesKeybindings,
resolveWindowShortcutAction,
type WindowShortcutInput
} from '../../shared/window-shortcut-policy'
@@ -29,6 +30,38 @@ type IsMobileEmulatorEnabled = () => boolean
const CONTROL_MODIFIERS = new Set(['control', 'ctrl'])
const MAC_COMMAND_MODIFIERS = new Set(['meta', 'command', 'cmd'])
const WHEEL_ZOOM_BLOCKING_MODIFIERS = new Set(['alt', 'shift'])
+const GUEST_WHEEL_ZOOM_DEDUPE_MS = 250
+
+type GuestWheelZoomDirection = Exclude
+
+const recentGuestWheelZoomByGuest = new WeakMap<
+ Electron.WebContents,
+ { direction: GuestWheelZoomDirection; at: number }
+>()
+
+function markGuestWheelZoom(guest: Electron.WebContents, direction: GuestWheelZoomDirection): void {
+ recentGuestWheelZoomByGuest.set(guest, { direction, at: Date.now() })
+}
+
+function consumeRecentGuestWheelZoom(
+ guest: Electron.WebContents,
+ direction: GuestWheelZoomDirection
+): boolean {
+ const recent = recentGuestWheelZoomByGuest.get(guest)
+ if (!recent) {
+ return false
+ }
+ const elapsed = Date.now() - recent.at
+ if (elapsed < 0 || elapsed > GUEST_WHEEL_ZOOM_DEDUPE_MS) {
+ recentGuestWheelZoomByGuest.delete(guest)
+ return false
+ }
+ if (recent.direction !== direction) {
+ return false
+ }
+ recentGuestWheelZoomByGuest.delete(guest)
+ return true
+}
function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet): boolean {
return mouse.modifiers?.some((modifier) => modifiers.has(modifier)) ?? false
@@ -37,7 +70,7 @@ function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet doubleTapDetector.reset()
type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean }
+ const forwardBrowserPageZoom = (
+ event: Electron.Event,
+ direction: BrowserPageZoomDirection
+ ): void => {
+ event.preventDefault()
+ const renderer = resolveRenderer(browserTabId)
+ renderer?.send('ui:zoomBrowserPage', direction)
+ }
+
const forwardShortcutInput = (
event: Electron.Event,
input: GuestShortcutInput,
@@ -284,11 +326,9 @@ export function setupGuestShortcutForwarding(args: {
): boolean => {
const keybindings = getKeybindings?.()
if (action?.type === 'zoom') {
- // Why: keyboard zoom is Orca chrome zoom. Focused guests bypass the
- // main-window shortcut path, so forward to the shared renderer zoom router.
- event.preventDefault()
- const renderer = resolveRenderer(browserTabId)
- renderer?.send('terminal:zoom', action.direction)
+ // Why: focused browser guests own page zoom, but their key events never
+ // reach the renderer-owned webview ref that can apply Orca's page zoom.
+ forwardBrowserPageZoom(event, action.direction)
return true
}
if (input.isAutoRepeat) {
@@ -521,11 +561,32 @@ export function setupGuestShortcutForwarding(args: {
forwardShortcutInput(event, input, action)
}
+ const zoomCommandHandler = (
+ event: Electron.Event,
+ zoomDirection: 'in' | 'out' | 'reset'
+ ): void => {
+ if (zoomDirection !== 'in' && zoomDirection !== 'out') {
+ return
+ }
+ // Why: some keyboard layouts/platforms turn Ctrl/Cmd +/- into Electron's
+ // native zoom command before `before-input-event` reaches the guest.
+ if (consumeRecentGuestWheelZoom(guest, zoomDirection)) {
+ event.preventDefault()
+ return
+ }
+ if (!nativeZoomCommandMatchesKeybindings(zoomDirection, process.platform, getKeybindings?.())) {
+ return
+ }
+ forwardBrowserPageZoom(event, zoomDirection)
+ }
+
guest.on('before-input-event', handler)
+ guest.on('zoom-changed', zoomCommandHandler)
guest.on('blur', resetDoubleTapDetector)
return () => {
try {
guest.off('before-input-event', handler)
+ guest.off('zoom-changed', zoomCommandHandler)
guest.off('blur', resetDoubleTapDetector)
} catch {
// Why: best-effort — guest may already be destroyed during teardown.
@@ -547,6 +608,7 @@ export function setupGuestMouseWheelZoomForwarding(args: {
// Why: wheel input over a focused webview does not reach renderer DOM
// handlers, so consume it here and forward to the existing page-zoom path.
event.preventDefault()
+ markGuestWheelZoom(guest, direction)
resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction)
}
diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts
index 87e3a0ea90d..51908be3253 100644
--- a/src/main/codex-accounts/runtime-home-service.test.ts
+++ b/src/main/codex-accounts/runtime-home-service.test.ts
@@ -1324,6 +1324,86 @@ describe('CodexRuntimeHomeService', () => {
}
})
+ it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => {
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ const wslHome = join(testState.userDataDir, 'wsl-home')
+ vi.doMock('../wsl', () => ({
+ getDefaultWslDistro: () => 'Ubuntu',
+ getWslHome: () => wslHome
+ }))
+ const systemCodexHomePath = join(wslHome, '.codex')
+ mkdirSync(systemCodexHomePath, { recursive: true })
+ writeFileSync(
+ join(systemCodexHomePath, 'config.toml'),
+ [
+ 'model_instructions_file = "instructions.md"',
+ '',
+ '[hooks.state."system-hooks:stop:0:0"]',
+ 'enabled = true',
+ '',
+ '[projects."/home/alice/repo"]',
+ 'trust_level = "trusted"',
+ ''
+ ].join('\n'),
+ 'utf-8'
+ )
+ const store = createStore(createSettings())
+
+ try {
+ const { CodexRuntimeHomeService } = await import('./runtime-home-service')
+ const service = new CodexRuntimeHomeService(store as never)
+ const wslRuntimeHomePath = join(
+ wslHome,
+ '.local',
+ 'share',
+ 'orca',
+ 'codex-runtime-home',
+ 'home'
+ )
+
+ expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
+ wslRuntimeHomePath
+ )
+ const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
+ const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
+ expect(runtimeConfig).toContain(
+ `model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'`
+ )
+ expect(runtimeConfig).toContain('[projects."/home/alice/repo"]')
+ expect(runtimeConfig).not.toContain('[hooks.state.')
+
+ // Why: WSL runtime configs are seeded once; Codex writes trust into them
+ // afterwards, so a relaunch must not clobber the seeded file.
+ writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8')
+ service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
+ expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]')
+ } finally {
+ if (originalPlatform) {
+ Object.defineProperty(process, 'platform', originalPlatform)
+ }
+ }
+ })
+
+ it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => {
+ const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service')
+
+ // Why: real UNC sources cannot back live fs operations in tests, so pin
+ // the UNC -> Linux-side anchor translation on the extracted seed function.
+ expect(
+ prepareWslRuntimeSeedConfig(
+ 'model_instructions_file = "instructions.md"\n',
+ '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
+ )
+ ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
+ expect(
+ prepareWslRuntimeSeedConfig(
+ 'model_instructions_file = "instructions.md"\n',
+ '\\\\wsl$\\Ubuntu\\home\\alice\\.codex'
+ )
+ ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
+ })
+
it('switches WSL accounts by rewriting one stable WSL runtime home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts
index 12d119a8510..4d5d8e4c645 100644
--- a/src/main/codex-accounts/runtime-home-service.ts
+++ b/src/main/codex-accounts/runtime-home-service.ts
@@ -40,7 +40,10 @@ import {
syncSystemCodexResourcesIntoManagedHome
} from '../codex/codex-home-paths'
import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge'
-import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
+import {
+ prepareSystemConfigForFreshRuntimeMirror,
+ syncSystemConfigIntoManagedCodexHome
+} from '../codex/codex-config-mirror'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
getWslSelectionKey,
@@ -705,7 +708,10 @@ export class CodexRuntimeHomeService {
for (const homePath of candidateHomes) {
const configPath = join(homePath, 'config.toml')
if (existsSync(configPath)) {
- copyFileSync(configPath, runtimeConfigPath)
+ writeFileAtomically(
+ runtimeConfigPath,
+ prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath)
+ )
return
}
}
@@ -1562,3 +1568,16 @@ export class CodexRuntimeHomeService {
rmSync(this.getSystemDefaultSnapshotPath(), { force: true })
}
}
+
+// Why: the seed config is read over UNC but consumed by Codex inside WSL, so
+// relative path-valued settings must anchor to the Linux-side source home; a
+// verbatim copy breaks Codex config load (os error 2).
+export function prepareWslRuntimeSeedConfig(
+ configContents: string,
+ sourceHomePath: string
+): string {
+ return prepareSystemConfigForFreshRuntimeMirror(
+ configContents,
+ parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath
+ )
+}
diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts
index ff3ad9ba3bd..416ce9af78b 100644
--- a/src/main/codex/codex-config-mirror.test.ts
+++ b/src/main/codex/codex-config-mirror.test.ts
@@ -23,7 +23,10 @@ vi.mock('node:os', async () => {
}
})
-import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
+import {
+ prepareSystemConfigForFreshRuntimeMirror,
+ syncSystemConfigIntoManagedCodexHome
+} from './codex-config-mirror'
let fakeHomeDir: string
let userDataDir: string
@@ -397,3 +400,32 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
expect(existsSync(getRuntimeConfigPath())).toBe(false)
})
})
+
+describe('prepareSystemConfigForFreshRuntimeMirror', () => {
+ it('rewrites relative paths against a Linux-side home and strips hook trust', () => {
+ const prepared = prepareSystemConfigForFreshRuntimeMirror(
+ [
+ 'model_instructions_file = "instructions.md"',
+ '',
+ '[features]',
+ 'codex_hooks = true',
+ '',
+ '[hooks.state."system-hooks:stop:0:0"]',
+ 'enabled = true',
+ '',
+ '[projects."/home/alice/repo"]',
+ 'trust_level = "trusted"',
+ ''
+ ].join('\r\n'),
+ '/home/alice/.codex'
+ )
+
+ // Why: WSL configs are consumed inside the distro, so rewrites must use
+ // posix join semantics regardless of the host platform.
+ expect(prepared).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
+ expect(prepared).toContain('hooks = true')
+ expect(prepared).not.toContain('codex_hooks')
+ expect(prepared).toContain('[projects."/home/alice/repo"]')
+ expect(prepared).not.toContain('[hooks.state."system-hooks:stop:0:0"]')
+ })
+})
diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts
index ff9f72c55fe..ce9208e9b05 100644
--- a/src/main/codex/codex-config-mirror.ts
+++ b/src/main/codex/codex-config-mirror.ts
@@ -35,17 +35,19 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
return
}
- const systemConfig = prepareSystemConfigForRuntimeMirror(
- systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '',
- dirname(systemConfigPath)
- )
+ const rawSystemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : ''
if (!runtimeConfigExists) {
- // Why: trust blocks reference a hooks.json path, so system-home hook trust
- // entries are not valid in Orca's runtime CODEX_HOME until install remaps them.
- writeFileAtomically(runtimeConfigPath, stripRuntimeOwnedTomlSections(systemConfig))
+ writeFileAtomically(
+ runtimeConfigPath,
+ prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, dirname(systemConfigPath))
+ )
return
}
+ const systemConfig = prepareSystemConfigForRuntimeMirror(
+ rawSystemConfig,
+ dirname(systemConfigPath)
+ )
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
if (mergedConfig !== runtimeConfig) {
@@ -60,6 +62,17 @@ function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: st
)
}
+// Why: trust blocks reference a hooks.json path, so system-home hook trust
+// entries are not valid in a fresh runtime CODEX_HOME until install remaps
+// them. Also seeds WSL runtime homes, where systemConfigDir must be the
+// Linux-side ~/.codex the config resolves against inside the distro.
+export function prepareSystemConfigForFreshRuntimeMirror(
+ config: string,
+ systemConfigDir: string
+): string {
+ return stripRuntimeOwnedTomlSections(prepareSystemConfigForRuntimeMirror(config, systemConfigDir))
+}
+
function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
if (!config.includes('codex_hooks')) {
return config
@@ -71,7 +84,9 @@ function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
for (let index = 0; index <= lines.length; index += 1) {
const line = lines[index]
- const isHeader = line === undefined || /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?$/.test(line)
+ // Why: CRLF configs keep a trailing \r after the split, so header anchors
+ // must tolerate it or Windows-shaped configs skip normalization entirely.
+ const isHeader = line === undefined || /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?\r?$/.test(line)
if (!isHeader) {
continue
}
@@ -80,7 +95,7 @@ function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
featureSections.push({ start: featureStart, end: index })
featureStart = null
}
- if (line !== undefined && /^[ \t]*\[features\][ \t]*(?:#.*)?$/.test(line)) {
+ if (line !== undefined && /^[ \t]*\[features\][ \t]*(?:#.*)?\r?$/.test(line)) {
featureStart = index
}
}
diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts
index 98e0b863b47..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)}`
})
@@ -2028,15 +2125,14 @@ describe('createPtySubprocess', () => {
}
}
- expect(spawnMock).toHaveBeenCalledWith(
- 'wsl.exe',
- expect.any(Array),
- expect.objectContaining({
- env: expect.objectContaining({
- ORCA_TERMINAL_HANDLE: 'term_wsl',
- WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
- })
- })
+ const spawnCall = spawnMock.mock.calls.at(-1)!
+ expect(spawnCall[0]).toBe('wsl.exe')
+ expect(spawnCall[1]).toEqual(expect.any(Array))
+ expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
+ // Why: the daemon inherits optional agent-hook env in development. This
+ // test owns only the terminal handle and Powerlevel10k WSLENV contract.
+ expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
+ expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV])
)
})
diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts
index 9ea9c68e0e5..e739b75ea25 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
diff --git a/src/main/ghostty/index.ts b/src/main/ghostty/index.ts
index ac8917154a1..8557193fe9f 100644
--- a/src/main/ghostty/index.ts
+++ b/src/main/ghostty/index.ts
@@ -5,6 +5,7 @@ import type { Store } from '../persistence'
import { findGhosttyConfigPaths } from './discovery'
import { parseGhosttyConfig } from './parser'
import { mapGhosttyToOrca } from './mapper'
+import { resolveGhosttyThemeColors } from './theme-resolution'
// Why: defensive upper bound on the Ghostty config size we're willing to read
// into memory on the main process. Real configs are a few KB; anything past
@@ -61,6 +62,44 @@ function mergeParsedConfig(
}
}
+function usesConditionalThemePair(themeName: string): boolean {
+ return themeName
+ .split(',')
+ .map((part) => part.trim())
+ .some((part) => part.startsWith('light:') || part.startsWith('dark:'))
+}
+
+// Why: `theme = ` is how most Ghostty configs pick their colors, so
+// dropping it silently loses the entire palette. Resolve the referenced theme
+// file and merge its colors as defaults: explicit config keys win, and config
+// palette entries are appended after the theme's so per-index overrides apply.
+async function applyThemeReference(parsed: Record): Promise {
+ const rawTheme = parsed['theme']
+ if (rawTheme === undefined) {
+ return []
+ }
+ delete parsed['theme']
+
+ const themeName = (Array.isArray(rawTheme) ? (rawTheme.at(-1) ?? '') : rawTheme).trim()
+ if (usesConditionalThemePair(themeName)) {
+ return ['theme (light:/dark: pairs not supported)']
+ }
+
+ const themeColors = await resolveGhosttyThemeColors(themeName)
+ if (themeColors === null) {
+ return ['theme (theme file not found)']
+ }
+
+ for (const [key, value] of Object.entries(themeColors)) {
+ if (key === 'palette') {
+ parsed[key] = [...asArray(value), ...asArray(parsed[key])]
+ } else if (parsed[key] === undefined) {
+ parsed[key] = value
+ }
+ }
+ return []
+}
+
export async function previewGhosttyImport(store: Store): Promise {
const configPaths = await findGhosttyConfigPaths()
if (configPaths.length === 0) {
@@ -93,7 +132,13 @@ export async function previewGhosttyImport(store: Store): Promise = {}
diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts
index 5c551139120..777110630c1 100644
--- a/src/main/ghostty/mapper-extended.test.ts
+++ b/src/main/ghostty/mapper-extended.test.ts
@@ -77,6 +77,110 @@ describe('mapGhosttyToOrca — window-padding', () => {
expect(result.diff).toEqual({})
expect(result.unsupportedKeys).toEqual(['window-padding-x'])
})
+
+ it('averages a dual-value window-padding-y', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-y': '10,8' })
+ expect(result.diff).toEqual({ terminalPaddingY: 9 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('averages a dual-value window-padding-x with surrounding whitespace', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-x': '16, 12' })
+ expect(result.diff).toEqual({ terminalPaddingX: 14 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('preserves odd-sum dual-value padding as a fractional average', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' })
+ expect(result.diff).toEqual({ terminalPaddingX: 1.5 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rejects a dual-value padding with an invalid half', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-y': '10,wide' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['window-padding-y'])
+ })
+
+ it('rejects a dual-value padding with an out-of-range half', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-y': '10,600' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['window-padding-y'])
+ })
+
+ it('rejects paddings with more than two values', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-y': '10,8,6' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['window-padding-y'])
+ })
+})
+
+describe('mapGhosttyToOrca — adjust-cell-height', () => {
+ it('maps a percentage to terminalLineHeight', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '35%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1.35 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rounds the mapped line height to two decimals', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '33%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1.33 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rounds half-boundary decimal percentages to the nearest hundredth', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '0.5%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1.01 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rounds higher half-boundary decimal percentages to the nearest hundredth', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '1.5%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1.02 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rounds high half-boundary decimal percentages without floating-point drift', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '101.5%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 2.02 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('accepts the inclusive 1x line-height floor', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '0%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('accepts the inclusive 3x line-height ceiling', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '200%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 3 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rejects a pixel value (not convertible to a line-height ratio)', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '2' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
+ })
+
+ it('rejects a negative percentage (below the 1x line-height floor)', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '-10%' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
+ })
+
+ it('rejects a percentage above the 3x line-height ceiling', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '250%' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
+ })
+
+ it('rejects values that only fall inside the ceiling after rounding', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '200.4%' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
+ })
})
describe('mapGhosttyToOrca — cursor-text', () => {
diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts
index 69abcbff4d7..7c0588f4c2b 100644
--- a/src/main/ghostty/mapper.ts
+++ b/src/main/ghostty/mapper.ts
@@ -4,6 +4,7 @@ import type {
GhosttyImportPreview
} from '../../shared/types'
import { HEX_COLOR_RE } from '../../shared/color-validation'
+import { parsePaddingValue, parseStrictInt } from './numeric-config-values'
const PALETTE_INDEX_MAP: Record = {
0: 'black',
@@ -34,18 +35,6 @@ type FieldResult =
const normalizeHex = (v: string): string => (v.startsWith('#') ? v : `#${v}`)
-// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty
-// config with `window-padding-x = 1e9` would sail through the mapper and land
-// an absurd value in the store. Restrict to plain decimal integers.
-const STRICT_INT_RE = /^-?\d+$/
-const parseStrictInt = (v: string): number | null => {
- if (!STRICT_INT_RE.test(v)) {
- return null
- }
- const num = Number(v)
- return Number.isFinite(num) ? num : null
-}
-
type FieldParser = (value: string, rawValue: string | string[]) => FieldResult
export function mapGhosttyToOrca(
@@ -171,21 +160,38 @@ export function mapGhosttyToOrca(
},
'window-padding-x': (v) => {
- const num = parseStrictInt(v)
- if (num === null || num < 0 || num > 512) {
+ const num = parsePaddingValue(v)
+ if (num === null) {
return null
}
return { key: 'terminalPaddingX', value: num }
},
'window-padding-y': (v) => {
- const num = parseStrictInt(v)
- if (num === null || num < 0 || num > 512) {
+ const num = parsePaddingValue(v)
+ if (num === null) {
return null
}
return { key: 'terminalPaddingY', value: num }
},
+ // Why: only percentages translate to xterm's line-height ratio; Ghostty's
+ // pixel form depends on the rendered cell height, which we can't know here.
+ // The settings UI clamps terminalLineHeight to [1, 3], so reject outside it.
+ 'adjust-cell-height': (v) => {
+ const match = /^\+?(\d+(?:\.\d+)?)%$/.exec(v.trim())
+ if (!match) {
+ return null
+ }
+ const percent = Number(match[1])
+ const rawLineHeight = 1 + percent / 100
+ if (rawLineHeight > 3) {
+ return null
+ }
+ const lineHeight = Math.round(100 + percent) / 100
+ return { key: 'terminalLineHeight', value: lineHeight }
+ },
+
'cursor-text': (v) => {
if (!HEX_COLOR_RE.test(v)) {
return null
diff --git a/src/main/ghostty/numeric-config-values.ts b/src/main/ghostty/numeric-config-values.ts
new file mode 100644
index 00000000000..f8a7ce7b468
--- /dev/null
+++ b/src/main/ghostty/numeric-config-values.ts
@@ -0,0 +1,30 @@
+// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty
+// config with `window-padding-x = 1e9` would sail through the mapper and land
+// an absurd value in the store. Restrict to plain decimal integers.
+const STRICT_INT_RE = /^-?\d+$/
+export const parseStrictInt = (v: string): number | null => {
+ if (!STRICT_INT_RE.test(v)) {
+ return null
+ }
+ const num = Number(v)
+ return Number.isFinite(num) ? num : null
+}
+
+// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings,
+// but Orca stores a single value per axis — average the pair so the total
+// padding along the axis stays the same.
+export const parsePaddingValue = (v: string): number | null => {
+ const parts = v.split(',')
+ if (parts.length > 2) {
+ return null
+ }
+ const nums: number[] = []
+ for (const part of parts) {
+ const num = parseStrictInt(part.trim())
+ if (num === null || num < 0 || num > 512) {
+ return null
+ }
+ nums.push(num)
+ }
+ return nums.reduce((sum, num) => sum + num, 0) / nums.length
+}
diff --git a/src/main/ghostty/theme-import.test.ts b/src/main/ghostty/theme-import.test.ts
new file mode 100644
index 00000000000..c2d2ec61ab9
--- /dev/null
+++ b/src/main/ghostty/theme-import.test.ts
@@ -0,0 +1,171 @@
+import type { Store } from '../persistence'
+import type { GlobalSettings } from '../../shared/types'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { statMock, readFileMock } = vi.hoisted(() => ({
+ statMock: vi.fn(),
+ readFileMock: vi.fn()
+}))
+
+vi.mock('fs/promises', () => ({
+ stat: statMock,
+ readFile: readFileMock
+}))
+
+vi.mock('os', () => ({
+ platform: vi.fn(() => 'darwin'),
+ homedir: vi.fn(() => '/Users/alice')
+}))
+
+import { previewGhosttyImport } from './index'
+
+const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
+const originalGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR
+
+beforeEach(() => {
+ delete process.env.XDG_CONFIG_HOME
+ delete process.env.GHOSTTY_RESOURCES_DIR
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+ if (originalXdgConfigHome !== undefined) {
+ process.env.XDG_CONFIG_HOME = originalXdgConfigHome
+ } else {
+ delete process.env.XDG_CONFIG_HOME
+ }
+ if (originalGhosttyResourcesDir !== undefined) {
+ process.env.GHOSTTY_RESOURCES_DIR = originalGhosttyResourcesDir
+ } else {
+ delete process.env.GHOSTTY_RESOURCES_DIR
+ }
+})
+
+function createStore(settings: Record = {}): Store {
+ return {
+ getSettings: () => settings as GlobalSettings
+ } as Store
+}
+
+describe('previewGhosttyImport theme references', () => {
+ it('resolves a theme reference into color overrides', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ const themePath = '/Users/alice/.config/ghostty/themes/Tomorrow Night Bright'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath || p === themePath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockImplementation(async (p: string) => {
+ if (p === themePath) {
+ return 'palette = 1=#d54e53\nbackground = #000000\nforeground = #eaeaea\n'
+ }
+ return 'theme = Tomorrow Night Bright\nfont-size = 14\n'
+ })
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({
+ terminalFontSize: 14,
+ terminalColorOverrides: {
+ red: '#d54e53',
+ background: '#000000',
+ foreground: '#eaeaea'
+ }
+ })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('lets explicit config colors override theme colors', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ const themePath = '/Users/alice/.config/ghostty/themes/night'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath || p === themePath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockImplementation(async (p: string) => {
+ if (p === themePath) {
+ return 'palette = 1=#d54e53\npalette = 2=#b9ca4a\nbackground = #000000\n'
+ }
+ return 'theme = night\nbackground = #101010\npalette = 1=#ff0000\n'
+ })
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({
+ terminalColorOverrides: {
+ // Why: config's palette index 1 and background win; theme keeps index 2.
+ red: '#ff0000',
+ green: '#b9ca4a',
+ background: '#101010'
+ }
+ })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('resolves an absolute theme path without probing theme search dirs', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ const themePath = '/Users/alice/themes/work'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath || p === themePath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockImplementation(async (p: string) => {
+ if (p === themePath) {
+ return 'background = #202020\nforeground = #f0f0f0\n'
+ }
+ return `theme = ${themePath}\n`
+ })
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({
+ terminalColorOverrides: {
+ background: '#202020',
+ foreground: '#f0f0f0'
+ }
+ })
+ expect(statMock).toHaveBeenCalledWith(themePath)
+ expect(statMock).not.toHaveBeenCalledWith(
+ '/Users/alice/.config/ghostty/themes/Users/alice/themes/work'
+ )
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('marks an unresolvable theme as unsupported', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('theme = Missing Theme\n')
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['theme (theme file not found)'])
+ })
+
+ it('marks light:/dark: theme pairs as unsupported', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('theme = light:Tomorrow,dark:Tomorrow Night\n')
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['theme (light:/dark: pairs not supported)'])
+ })
+})
diff --git a/src/main/ghostty/theme-resolution.test.ts b/src/main/ghostty/theme-resolution.test.ts
new file mode 100644
index 00000000000..607e69a39e8
--- /dev/null
+++ b/src/main/ghostty/theme-resolution.test.ts
@@ -0,0 +1,171 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { statMock, readFileMock } = vi.hoisted(() => ({
+ statMock: vi.fn(),
+ readFileMock: vi.fn()
+}))
+
+vi.mock('fs/promises', () => ({
+ stat: statMock,
+ readFile: readFileMock
+}))
+
+vi.mock('os', () => ({
+ platform: vi.fn(() => 'darwin'),
+ homedir: vi.fn(() => '/Users/alice')
+}))
+
+import { getGhosttyThemeSearchDirs, resolveGhosttyThemeColors } from './theme-resolution'
+
+const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
+const originalGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR
+
+beforeEach(() => {
+ delete process.env.XDG_CONFIG_HOME
+ delete process.env.GHOSTTY_RESOURCES_DIR
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+ if (originalXdgConfigHome !== undefined) {
+ process.env.XDG_CONFIG_HOME = originalXdgConfigHome
+ } else {
+ delete process.env.XDG_CONFIG_HOME
+ }
+ if (originalGhosttyResourcesDir !== undefined) {
+ process.env.GHOSTTY_RESOURCES_DIR = originalGhosttyResourcesDir
+ } else {
+ delete process.env.GHOSTTY_RESOURCES_DIR
+ }
+})
+
+const THEME_FILE = `
+palette = 0=#000000
+palette = 1=#d54e53
+background = #000000
+foreground = #eaeaea
+cursor-color = #eaeaea
+selection-background = #424242
+selection-foreground = #eaeaea
+`
+
+describe('getGhosttyThemeSearchDirs', () => {
+ it('probes XDG, then the Ghostty.app resources dir', () => {
+ delete process.env.XDG_CONFIG_HOME
+ expect(getGhosttyThemeSearchDirs()).toEqual([
+ '/Users/alice/.config/ghostty/themes',
+ '/Applications/Ghostty.app/Contents/Resources/ghostty/themes'
+ ])
+ })
+
+ it('honors XDG_CONFIG_HOME for the custom themes dir', () => {
+ process.env.XDG_CONFIG_HOME = '/xdg'
+ expect(getGhosttyThemeSearchDirs()[0]).toBe('/xdg/ghostty/themes')
+ })
+
+ it('honors GHOSTTY_RESOURCES_DIR for bundled themes', () => {
+ process.env.GHOSTTY_RESOURCES_DIR = '/opt/ghostty'
+ expect(getGhosttyThemeSearchDirs()).toEqual([
+ '/Users/alice/.config/ghostty/themes',
+ '/opt/ghostty/themes'
+ ])
+ })
+})
+
+describe('resolveGhosttyThemeColors', () => {
+ it('reads the first matching theme file and returns only color keys', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Users/alice/.config/ghostty/themes/Tomorrow Night Bright') {
+ return { isFile: () => true, size: THEME_FILE.length }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue(THEME_FILE)
+
+ const result = await resolveGhosttyThemeColors('Tomorrow Night Bright')
+ expect(result).toEqual({
+ palette: ['0=#000000', '1=#d54e53'],
+ background: '#000000',
+ foreground: '#eaeaea',
+ 'cursor-color': '#eaeaea',
+ 'selection-background': '#424242',
+ 'selection-foreground': '#eaeaea'
+ })
+ })
+
+ it('falls through to later dirs when earlier ones miss', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Applications/Ghostty.app/Contents/Resources/ghostty/themes/Tomorrow') {
+ return { isFile: () => true, size: THEME_FILE.length }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('background = #1d1f21')
+
+ const result = await resolveGhosttyThemeColors('Tomorrow')
+ expect(result).toEqual({ background: '#1d1f21' })
+ })
+
+ it('resolves absolute theme paths without searching named theme dirs', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Users/alice/themes/work') {
+ return { isFile: () => true, size: 64 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('background = #1d1f21')
+
+ const result = await resolveGhosttyThemeColors('/Users/alice/themes/work')
+ expect(result).toEqual({ background: '#1d1f21' })
+ expect(statMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('drops non-color keys a theme file might carry', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Users/alice/.config/ghostty/themes/custom') {
+ return { isFile: () => true, size: 64 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('background = #101010\nfont-size = 20')
+
+ const result = await resolveGhosttyThemeColors('custom')
+ expect(result).toEqual({ background: '#101010' })
+ })
+
+ it('returns null when the theme is not found anywhere', async () => {
+ statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
+ expect(await resolveGhosttyThemeColors('Missing Theme')).toBeNull()
+ })
+
+ it('rejects names containing path separators or traversal', async () => {
+ expect(await resolveGhosttyThemeColors('../etc/passwd')).toBeNull()
+ expect(await resolveGhosttyThemeColors('a/b')).toBeNull()
+ expect(await resolveGhosttyThemeColors('a\\b')).toBeNull()
+ expect(await resolveGhosttyThemeColors('..')).toBeNull()
+ expect(statMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects oversized theme files', async () => {
+ statMock.mockImplementation(async () => ({ isFile: () => true, size: 10_000_000 }))
+ expect(await resolveGhosttyThemeColors('huge')).toBeNull()
+ expect(readFileMock).not.toHaveBeenCalled()
+ expect(statMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not fall through when a shadowing theme path is invalid', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Users/alice/.config/ghostty/themes/night') {
+ return { isFile: () => false, size: 128 }
+ }
+ if (p === '/Applications/Ghostty.app/Contents/Resources/ghostty/themes/night') {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+
+ expect(await resolveGhosttyThemeColors('night')).toBeNull()
+ expect(readFileMock).not.toHaveBeenCalled()
+ expect(statMock).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/main/ghostty/theme-resolution.ts b/src/main/ghostty/theme-resolution.ts
new file mode 100644
index 00000000000..eedb2c1592d
--- /dev/null
+++ b/src/main/ghostty/theme-resolution.ts
@@ -0,0 +1,120 @@
+import { homedir, platform } from 'node:os'
+import path from 'node:path'
+import { readFile, stat } from 'node:fs/promises'
+import { parseGhosttyConfig } from './parser'
+
+// Why: theme files ship a few dozen short lines; anything larger is not a
+// Ghostty theme and should not be read into the main process.
+const MAX_THEME_BYTES = 262_144
+type ThemeColors = Record
+type ThemeReadResult =
+ | { status: 'found'; colors: ThemeColors }
+ | { status: 'missing' }
+ | { status: 'invalid' }
+
+// Why: only color-bearing keys may flow from a theme into the import — a theme
+// file must not be able to smuggle font/window settings past the user's config.
+const THEME_COLOR_KEYS = new Set([
+ 'palette',
+ 'background',
+ 'foreground',
+ 'cursor-color',
+ 'cursor-text',
+ 'selection-background',
+ 'selection-foreground',
+ 'bold-color',
+ 'split-divider-color'
+])
+
+function xdgThemeDirs(home: string): string[] {
+ if (process.env.XDG_CONFIG_HOME) {
+ return [path.posix.join(process.env.XDG_CONFIG_HOME, 'ghostty', 'themes')]
+ }
+ return [path.posix.join(home, '.config', 'ghostty', 'themes')]
+}
+
+function resourceThemeDirs(plat: 'darwin' | 'linux'): string[] {
+ if (process.env.GHOSTTY_RESOURCES_DIR) {
+ return [path.posix.join(process.env.GHOSTTY_RESOURCES_DIR, 'themes')]
+ }
+
+ if (plat === 'darwin') {
+ return ['/Applications/Ghostty.app/Contents/Resources/ghostty/themes']
+ }
+ return ['/usr/share/ghostty/themes', '/usr/local/share/ghostty/themes']
+}
+
+// Why: mirrors Ghostty's lookup — user themes shadow the bundled themes shipped
+// inside the Ghostty resources directory.
+export function getGhosttyThemeSearchDirs(): string[] {
+ const home = homedir()
+ const plat = platform()
+ if (plat !== 'darwin' && plat !== 'linux') {
+ // Why: Ghostty has no Windows build, so there are no named theme dirs to probe.
+ return []
+ }
+ return [...xdgThemeDirs(home), ...resourceThemeDirs(plat)]
+}
+
+function isMissingThemeError(err: unknown): boolean {
+ if (typeof err !== 'object' || err === null || !('code' in err)) {
+ return false
+ }
+ const code = (err as { code?: unknown }).code
+ return code === 'ENOENT' || code === 'ENOTDIR'
+}
+
+function isRelativeThemeName(name: string): boolean {
+ return !name.includes('/') && !name.includes('\\') && name !== '..' && name !== '.'
+}
+
+function themeColorsFromContent(content: string): ThemeColors {
+ const parsed = parseGhosttyConfig(content)
+ const colors: ThemeColors = {}
+ for (const [key, value] of Object.entries(parsed)) {
+ if (THEME_COLOR_KEYS.has(key)) {
+ colors[key] = value
+ }
+ }
+ return colors
+}
+
+async function readThemeColors(themePath: string): Promise {
+ let content: string
+ try {
+ const info = await stat(themePath)
+ if (!info.isFile() || info.size > MAX_THEME_BYTES) {
+ return { status: 'invalid' }
+ }
+ content = await readFile(themePath, 'utf-8')
+ } catch (err) {
+ return { status: isMissingThemeError(err) ? 'missing' : 'invalid' }
+ }
+
+ return { status: 'found', colors: themeColorsFromContent(content) }
+}
+
+export async function resolveGhosttyThemeColors(name: string): Promise {
+ if (path.isAbsolute(name)) {
+ const result = await readThemeColors(name)
+ return result.status === 'found' ? result.colors : null
+ }
+
+ // Why: relative theme names become filenames below; only absolute paths may
+ // contain separators, matching Ghostty's traversal guard.
+ if (!isRelativeThemeName(name)) {
+ return null
+ }
+
+ for (const dir of getGhosttyThemeSearchDirs()) {
+ const themePath = path.posix.join(dir, name)
+ const result = await readThemeColors(themePath)
+ if (result.status === 'found') {
+ return result.colors
+ }
+ if (result.status === 'invalid') {
+ return null
+ }
+ }
+ return null
+}
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/runner.test.ts b/src/main/git/runner.test.ts
index 24a031bbceb..57b04831acc 100644
--- a/src/main/git/runner.test.ts
+++ b/src/main/git/runner.test.ts
@@ -3,12 +3,30 @@
// and stderr extraction from execFile rejections (err.message is unreliable).
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
+ appendGitConfigEnv,
extractExecError,
isTransientGhError,
+ nonInteractiveGitEnv,
parseRetryAfterMs,
+ promptGuardGitEnv,
redirectPortedHostnameToEnv
} from './runner'
+// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol
+// back into a plain key→value map so tests can assert on it directly.
+function readGitConfigEnv(env: NodeJS.ProcessEnv): Record {
+ const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10)
+ const config: Record = {}
+ for (let i = 0; i < count; i++) {
+ const key = env[`GIT_CONFIG_KEY_${i}`]
+ const value = env[`GIT_CONFIG_VALUE_${i}`]
+ if (key !== undefined && value !== undefined) {
+ config[key] = value
+ }
+ }
+ return config
+}
+
describe('redirectPortedHostnameToEnv', () => {
it('moves a ported --hostname into GITLAB_HOST and strips the flag', () => {
const { args, options } = redirectPortedHostnameToEnv(
@@ -147,3 +165,56 @@ describe('extractExecError', () => {
})
})
})
+
+describe('appendGitConfigEnv', () => {
+ it('injects entries starting at count 0 when none exist', () => {
+ const env = appendGitConfigEnv({ PATH: '/usr/bin' }, [['credential.interactive', 'false']])
+ expect(env.GIT_CONFIG_COUNT).toBe('1')
+ expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
+ expect(env.GIT_CONFIG_VALUE_0).toBe('false')
+ expect(env.PATH).toBe('/usr/bin')
+ })
+
+ it('composes with an existing count instead of clobbering caller config', () => {
+ const env = appendGitConfigEnv(
+ { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.quotePath', GIT_CONFIG_VALUE_0: 'false' },
+ [['credential.guiPrompt', 'false']]
+ )
+ expect(env.GIT_CONFIG_COUNT).toBe('2')
+ // Existing entry preserved.
+ expect(env.GIT_CONFIG_KEY_0).toBe('core.quotePath')
+ expect(env.GIT_CONFIG_VALUE_0).toBe('false')
+ // New entry appended at the next index.
+ expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt')
+ expect(env.GIT_CONFIG_VALUE_1).toBe('false')
+ })
+})
+
+describe('promptGuardGitEnv credential-interactivity disable (STA-1292)', () => {
+ it('disables the GCM GUI prompt without nuking the credential helper', () => {
+ const env = promptGuardGitEnv({ PATH: '/usr/bin' })
+ // GCM: never show the GUI, but still serve cached credentials.
+ expect(env.GCM_INTERACTIVE).toBe('never')
+ const config = readGitConfigEnv(env)
+ expect(config['credential.interactive']).toBe('false')
+ expect(config['credential.guiPrompt']).toBe('false')
+ // Regression guard: we must NOT clear the helper — that would break
+ // cached-credential auth for private repos.
+ expect(config['credential.helper']).toBeUndefined()
+ // Existing prompt guards remain intact.
+ expect(env.GIT_TERMINAL_PROMPT).toBe('0')
+ })
+})
+
+describe('nonInteractiveGitEnv credential-interactivity disable (STA-1292)', () => {
+ it('carries the credential-interactivity disable through from promptGuardGitEnv', () => {
+ const env = nonInteractiveGitEnv({ PATH: '/usr/bin' })
+ expect(env.GCM_INTERACTIVE).toBe('never')
+ const config = readGitConfigEnv(env)
+ expect(config['credential.interactive']).toBe('false')
+ expect(config['credential.guiPrompt']).toBe('false')
+ expect(config['credential.helper']).toBeUndefined()
+ // Its own BatchMode SSH guard is still applied and unaffected.
+ expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
+ })
+})
diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts
index 325d92220e3..ec0d1d7b84d 100644
--- a/src/main/git/runner.ts
+++ b/src/main/git/runner.ts
@@ -526,13 +526,47 @@ export function gitOptionalLocksDisabledEnv(
}
}
-function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
- return {
- ...env,
- GIT_TERMINAL_PROMPT: '0',
- GIT_ASKPASS: env.GIT_ASKPASS ?? '',
- SSH_ASKPASS: env.SSH_ASKPASS ?? ''
- }
+/**
+ * Append git config entries through the GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n /
+ * GIT_CONFIG_VALUE_n env protocol (git >= 2.31), composing with any count
+ * already present in `env` so we never clobber config a caller injected the
+ * same way.
+ */
+export function appendGitConfigEnv(
+ env: NodeJS.ProcessEnv,
+ entries: readonly (readonly [key: string, value: string])[]
+): NodeJS.ProcessEnv {
+ const parsed = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10)
+ const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0
+ const next = { ...env }
+ entries.forEach(([key, value], index) => {
+ next[`GIT_CONFIG_KEY_${base + index}`] = key
+ next[`GIT_CONFIG_VALUE_${base + index}`] = value
+ })
+ next.GIT_CONFIG_COUNT = String(base + entries.length)
+ return next
+}
+
+export function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+ return appendGitConfigEnv(
+ {
+ ...env,
+ GIT_TERMINAL_PROMPT: '0',
+ GIT_ASKPASS: env.GIT_ASKPASS ?? '',
+ SSH_ASKPASS: env.SSH_ASKPASS ?? '',
+ // Why: Git Credential Manager ignores GIT_TERMINAL_PROMPT / GIT_ASKPASS and
+ // pops a GUI on first auth — the Windows worktree-create hang (STA-1292).
+ // `never` suppresses the prompt while still serving cached credentials.
+ GCM_INTERACTIVE: 'never'
+ },
+ // Why: disable only the *interactive* credential prompt, NOT the helper
+ // itself — an empty credential.helper would break cached-credential auth for
+ // private repos. Harmless on macOS/Linux (no GCM) and on the SSH path.
+ [
+ ['credential.interactive', 'false'],
+ ['credential.guiPrompt', 'false']
+ ]
+ )
}
/**
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/git/worktree.test.ts b/src/main/git/worktree.test.ts
index 5bc272b98d1..43269a065a1 100644
--- a/src/main/git/worktree.test.ts
+++ b/src/main/git/worktree.test.ts
@@ -25,7 +25,8 @@ import {
listWorktrees,
moveWorktree,
parseWorktreeList,
- removeWorktree
+ removeWorktree,
+ WORKTREE_ADD_TIMEOUT_MS
} from './worktree'
describe('listWorktrees in-flight sharing', () => {
@@ -482,7 +483,7 @@ describe('addWorktree', () => {
'/repo-feature',
'refs/remotes/origin/main'
],
- { cwd: '/repo' }
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
],
[
[
@@ -507,10 +508,30 @@ describe('addWorktree', () => {
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
- [['worktree', 'add', '/repo-feature', 'feature/test'], { cwd: '/repo' }]
+ [
+ ['worktree', 'add', '/repo-feature', 'feature/test'],
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
+ ]
])
})
+ it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => {
+ // Why: without a timeout, a OneDrive cloud-placeholder checkout can stall
+ // `git worktree add` for minutes. Assert the runner receives a non-zero
+ // timeout so a stuck create fails fast instead of hanging forever.
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
+
+ await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
+ checkoutExistingBranch: true
+ })
+
+ const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
+ ([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add'
+ )
+ expect(worktreeAddCall?.[1]).toMatchObject({ timeout: WORKTREE_ADD_TIMEOUT_MS })
+ expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0)
+ })
+
it('does not write branch base config when no base branch is provided', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set
@@ -520,7 +541,7 @@ describe('addWorktree', () => {
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'],
- { cwd: '/repo' }
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
])
@@ -602,7 +623,7 @@ describe('addWorktree', () => {
'/repo-feature',
'refs/remotes/origin/main'
],
- { cwd: '/repo' }
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
],
[
[
@@ -640,7 +661,7 @@ describe('addWorktree', () => {
'/repo-feature',
'refs/remotes/origin/main'
],
- { cwd: '/repo' }
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
],
[
[
@@ -679,7 +700,7 @@ describe('addWorktree', () => {
'/repo-feature',
'refs/remotes/origin/main'
],
- { cwd: '/repo' }
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
],
[
[
@@ -719,7 +740,7 @@ describe('addWorktree', () => {
'/repo-feature',
'refs/remotes/origin/main'
],
- { cwd: '/repo' }
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
]
])
})
@@ -769,7 +790,7 @@ describe('addWorktree', () => {
'/repo-feature',
'refs/remotes/origin/main'
],
- { cwd: '/repo' }
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
],
[
[
@@ -1283,7 +1304,7 @@ describe('addWorktree', () => {
'/repo-feature',
'refs/heads/main'
],
- { cwd: '/repo' }
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
])
})
diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts
index 5a2e875a460..eb30e13ad0a 100644
--- a/src/main/git/worktree.ts
+++ b/src/main/git/worktree.ts
@@ -68,6 +68,12 @@ type LocalBaseRefRefreshability =
const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
+// Why: bound `git worktree add` so a OneDrive cloud-placeholder base path can't
+// stall its checkout writes for minutes (STA-1292) — a stuck create then fails
+// fast instead of spinning forever. Generous enough not to kill a legit large
+// checkout (mirrors the SYNC runner's cloud-placeholder floor, #7225).
+export const WORKTREE_ADD_TIMEOUT_MS = 180_000
+
function gitExecOptions(
cwd: string,
options: GitWorktreeExecOptions = {}
@@ -878,7 +884,12 @@ async function performAddWorktree(
args.push(effectiveBase)
}
}
- await gitExecFileAsync(args, gitExecOptions(repoPath, options))
+ await gitExecFileAsync(args, {
+ ...gitExecOptions(repoPath, options),
+ // Why: bound the checkout so a OneDrive cloud-placeholder stall (STA-1292)
+ // fails fast rather than hanging worktree creation indefinitely.
+ timeout: WORKTREE_ADD_TIMEOUT_MS
+ })
if (options.checkoutExistingBranch) {
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts
index 91d8e5ad07e..8783b2a4af4 100644
--- a/src/main/github/pr-start-point.test.ts
+++ b/src/main/github/pr-start-point.test.ts
@@ -22,12 +22,12 @@ describe('resolveGitHubPrStartPoint', () => {
getPullRequestPushTargetMock.mockResolvedValue({
pushTarget: {
remoteName: 'pr-contributor-orca',
- branchName: 'feat/onboarding-model-choice-782',
+ branchName: 'fix-issue-6933',
remoteUrl: 'git@github.com:contributor/orca.git'
}
})
const fetchRemoteTrackingRef = vi.fn(async (_remote: string, branch: string) => {
- if (branch === 'feat/onboarding-model-choice-782') {
+ if (branch === 'fix-issue-6933') {
throw new Error('fatal: could not find remote ref')
}
})
@@ -40,28 +40,25 @@ describe('resolveGitHubPrStartPoint', () => {
const result = await resolveGitHubPrStartPoint({
repoPath: '/repo-root',
- prNumber: 1849,
- headRefName: 'feat/onboarding-model-choice-782',
+ prNumber: 6934,
+ headRefName: 'fix-issue-6933',
baseRefName: 'main',
gitExec,
fetchRemoteTrackingRef,
resolveRemote: async () => 'origin'
})
- expect(fetchRemoteTrackingRef).toHaveBeenCalledWith(
- 'origin',
- 'feat/onboarding-model-choice-782'
- )
+ expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'fix-issue-6933')
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'main')
- expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'])
+ expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/6934/head'])
expect(result).toEqual({
baseBranch: 'def456',
compareBaseRef: 'refs/remotes/origin/main',
headSha: 'def456',
- branchNameOverride: 'feat/onboarding-model-choice-782',
+ branchNameOverride: 'fix-issue-6933',
pushTarget: {
remoteName: 'pr-contributor-orca',
- branchName: 'feat/onboarding-model-choice-782',
+ branchName: 'fix-issue-6933',
remoteUrl: 'git@github.com:contributor/orca.git'
}
})
diff --git a/src/main/index.ts b/src/main/index.ts
index 836ada978cb..a103429af3f 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -2121,6 +2121,8 @@ app.whenReady().then(async () => {
)
}
}
+ // Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
+ automations.start()
await printServeReady(serveOptions)
return
}
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/pty.test.ts b/src/main/ipc/pty.test.ts
index 5e0dcdbc7bc..e949a9c69a5 100644
--- a/src/main/ipc/pty.test.ts
+++ b/src/main/ipc/pty.test.ts
@@ -9,6 +9,7 @@ import {
TERMINAL_INPUT_MAX_BYTES
} from '../../shared/terminal-input'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
const isWindowsHost = process.platform === 'win32'
const posixOnlyIt = isWindowsHost ? it.skip : it
@@ -1366,6 +1367,7 @@ describe('registerPtyHandlers', () => {
// from main and PR #2662 command threading for OMP target selection.
spawnArgs?: {
cwd?: string
+ worktreeId?: string
shellOverride?: string
command?: string
envToDelete?: string[]
@@ -1559,7 +1561,10 @@ describe('registerPtyHandlers', () => {
CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
},
- { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo' }
+ {
+ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo',
+ worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\test\\repo'
+ }
)
const { env } = spawnOptions
expect(env.CODEX_HOME).toBeUndefined()
@@ -4415,7 +4420,7 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
- worktreeId: 'wt-1',
+ worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: { ORCA_PANE_KEY: paneKey },
@@ -4429,12 +4434,12 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
- worktreeId: 'wt-1',
+ worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: {
ORCA_TAB_ID: 'tab-race',
- ORCA_WORKTREE_ID: 'wt-1'
+ ORCA_WORKTREE_ID: 'repo-1::/tmp'
}
}) as Promise<{ id: string }>
await Promise.resolve()
@@ -4447,7 +4452,7 @@ describe('registerPtyHandlers', () => {
])
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
- worktreeId: 'wt-1',
+ worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
ptyId: 'pty-shared',
@@ -4529,13 +4534,13 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
- worktreeId: 'wt-1',
+ worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: {
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-race',
- ORCA_WORKTREE_ID: 'wt-1'
+ ORCA_WORKTREE_ID: 'repo-1::/tmp'
}
}) as Promise<{ id: string }>
await Promise.resolve()
@@ -4545,7 +4550,7 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
- worktreeId: 'wt-1',
+ worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: { ORCA_PANE_KEY: paneKey },
@@ -4561,7 +4566,7 @@ describe('registerPtyHandlers', () => {
])
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
- worktreeId: 'wt-1',
+ worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
ptyId: 'pty-renderer',
@@ -5915,6 +5920,22 @@ describe('registerPtyHandlers', () => {
})
})
+ it('passes floating terminal cwds through to the spawned shell', async () => {
+ // Why: the floating sentinel has no worktree root; its cwd is validated
+ // against trusted-directory grants before it reaches pty:spawn.
+ registerPtyHandlers(mainWindow as never)
+
+ await handlers.get('pty:spawn')!(null, {
+ cols: 80,
+ rows: 24,
+ cwd: '/tmp/floating-notes',
+ worktreeId: FLOATING_TERMINAL_WORKTREE_ID
+ })
+
+ const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }]
+ expect(options.cwd).toBe('/tmp/floating-notes')
+ })
+
it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => {
const originalPlatform = process.platform
const originalUserProfile = process.env.USERPROFILE
@@ -5925,8 +5946,10 @@ describe('registerPtyHandlers', () => {
})
process.env.USERPROFILE = 'C:\\Users\\jinwo'
+ // Why: the startup-cwd guard normalizes separators, so the provider sees
+ // the forward-slash UNC form.
existsSyncMock.mockImplementation((targetPath: string) => {
- if (targetPath === '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') {
+ if (targetPath === '//wsl.localhost/Ubuntu/home/jin/missing') {
return false
}
return true
@@ -5939,10 +5962,11 @@ describe('registerPtyHandlers', () => {
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
- cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing'
+ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing',
+ worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\jin'
})
).rejects.toThrow(
- 'Working directory "\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing" does not exist.'
+ 'Working directory "//wsl.localhost/Ubuntu/home/jin/missing" does not exist.'
)
expect(spawnMock).not.toHaveBeenCalled()
} finally {
@@ -8719,7 +8743,8 @@ describe('registerPtyHandlers', () => {
const result = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
- cwd: '/tmp'
+ cwd: '/tmp',
+ worktreeId: 'repo-1::/tmp'
})
expect(result).toEqual({ id: expect.any(String), pid: 12345 })
@@ -8759,7 +8784,8 @@ describe('registerPtyHandlers', () => {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
- cwd: '/tmp'
+ cwd: '/tmp',
+ worktreeId: 'repo-1::/tmp'
})
expect(spawnMock).toHaveBeenCalledTimes(1)
@@ -9104,6 +9130,7 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
+ worktreeId: 'repo-1::/tmp',
env: { SHELL: '/opt/homebrew/bin/bash' }
})
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 7caed41293b..ec1f65f84a8 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -81,6 +81,7 @@ import {
parsePaneKey
} from '../../shared/stable-pane-id'
import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd'
+import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
import {
clearMigrationUnsupportedPty,
clearMigrationUnsupportedPtysForPaneKey
@@ -2356,13 +2357,17 @@ export function registerPtyHandlers(
const resolveGuardedPtySpawnCwd = (
worktreeId: string | undefined,
- cwd: string | undefined
+ cwd: string | undefined,
+ connectionId?: string | null
): string | undefined =>
resolveTerminalStartupCwdForWorkspace({
workspaceId: worktreeId,
requestedCwd: cwd,
resolveFolderWorkspacePath: (folderWorkspaceId) =>
- store?.getFolderWorkspace(folderWorkspaceId)?.folderPath
+ store?.getFolderWorkspace(folderWorkspaceId)?.folderPath,
+ // Why: realpath only makes sense on the local filesystem; SSH worktree
+ // paths live on the remote host.
+ canonicalizePath: localTerminalCwdCanonicalizer(connectionId)
})
// Why: the runtime controller must route through getProviderForPty() so that
@@ -2375,7 +2380,7 @@ export function registerPtyHandlers(
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
- const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd)
+ const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
@@ -3008,7 +3013,7 @@ export function registerPtyHandlers(
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
- const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd)
+ const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
spawnTiming.mark('preflight')
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts
index 0b29082bf1e..2a9a66df94d 100644
--- a/src/main/ipc/repos-remote.test.ts
+++ b/src/main/ipc/repos-remote.test.ts
@@ -2441,6 +2441,12 @@ describe('repos:getBaseRefDefault envelope', () => {
match: 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: isRevParseFor('refs/remotes/origin/main'),
+ respond: () => Promise.resolve({ stdout: '', stderr: '' })
+ },
{
match: isRemoteList,
respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
@@ -2471,6 +2477,12 @@ describe('repos:getBaseRefDefault envelope', () => {
match: 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: isRevParseFor('refs/remotes/origin/main'),
+ respond: () => Promise.resolve({ stdout: '', stderr: '' })
+ },
{
match: isRemoteList,
respond: () => Promise.reject(new Error('relay exec failed'))
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 86d18129826..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
@@ -118,6 +123,9 @@ import {
const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000
const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512
+// Why: bound the create-path fallback `git fetch origin` so a Windows
+// credential-manager GUI hang (STA-1292) can't wedge worktree creation forever.
+const CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS = 60_000
const sshWorktreeCreateFetchInflight = new Map>()
const sshWorktreeCreateFetchCompletedAt = new Map()
const sshWorktreeCreateFetchQueueTail = new Map>()
@@ -605,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,
@@ -623,6 +660,43 @@ 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
+// fetch fails. Require a resolved object id: a missing ref exits non-zero.
+async function hasRemoteTrackingRefSsh(
+ provider: SshGitProvider,
+ repoPath: string,
+ ref: string
+): Promise {
+ try {
+ const { stdout } = await provider.exec(
+ ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
+ repoPath
+ )
+ return stdout.trim().length > 0
+ } catch {
+ return false
+ }
+}
+
async function canCheckoutExistingLocalBranchSsh(
provider: SshGitProvider,
repoPath: string,
@@ -1195,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
}
@@ -1257,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
@@ -1448,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)
@@ -1465,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
@@ -1571,15 +1656,41 @@ export async function createRemoteWorktree(
}
}
+ // 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
+ }
+ }
+
if (remoteTrackingBase) {
try {
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase)
} catch {
- throw new Error(
- `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
- )
+ // Why: a failed refresh must not block creation when a usable local base
+ // ref already exists — `git worktree add` can still create from that
+ // (possibly stale but valid) ref, so a transient offline/auth failure does
+ // not make the workspace uncreatable. Probe AFTER registerRoot so relays
+ // that gate generic git.exec accept it; only hard-fail when there is no
+ // local ref to fall back on. Drift is reflected by the compare-to-base
+ // view once the remote is reachable again.
+ if (!(await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref))) {
+ throw new Error(
+ `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
+ )
+ }
}
- } 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.
@@ -1590,27 +1701,6 @@ 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 the local-base advisory probe as well as addWorktree.
- // Fresh/older relays may gate generic git.exec calls on registered roots; if
- // the probe runs first it degrades to "no suggestion" 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
- }
- }
-
const localBaseRefRefresh =
settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase
? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase)
@@ -1881,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
@@ -1914,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
@@ -1943,8 +2065,13 @@ export async function createLocalWorktree(
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
} else {
- if (!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))) {
- legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], localGitExecOptions)
+ if (
+ !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
+ ) {
+ legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], {
+ ...localGitExecOptions,
+ timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS
+ })
.then(() => undefined)
.catch(() => undefined)
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
@@ -2135,7 +2262,13 @@ export async function createLocalWorktree(
if (remoteTrackingRefresh) {
await timing.time('refresh_base_ref', async () => {
const result = await remoteTrackingRefresh.promise
- if (!result.ok) {
+ if (!result.ok && !remoteTrackingRefresh.hadLocalBaseRef) {
+ // Why: only block creation when the refresh failed AND there is no local
+ // base ref to fall back on. An existing local remote-tracking ref lets
+ // `git worktree add` proceed from 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 "${remoteTrackingRefresh.base.remote}". Check your network and try again.`
)
diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts
index 6f2857129e8..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 remote-tracking base refresh fails', 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',
@@ -3704,19 +3720,250 @@ 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',
+ 'master',
+ 'refs/remotes/origin/master'
+ )
+ })
+
+ 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',
+ displayName: 'ssh',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'conn-1',
+ worktreeBaseRef: 'origin/master'
+ }
+ const provider = {
+ exec: vi.fn().mockImplementation(async (args: string[]) => {
+ 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/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().mockResolvedValue(undefined),
+ addWorktree: vi.fn().mockResolvedValue(undefined),
+ listWorktrees: vi
+ .fn()
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([
+ {
+ path: '/remote/improve-dashboard',
+ head: 'abc123',
+ branch: 'refs/heads/improve-dashboard',
+ 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:create'](null, {
+ repoId: 'repo-ssh',
+ name: 'improve-dashboard'
+ })
+
+ expect(resolveDefaultBaseRefViaExecMock).toHaveBeenCalled()
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'origin',
'main',
'refs/remotes/origin/main'
)
+ 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 () => {
@@ -3921,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',
@@ -4176,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',
@@ -4205,30 +4615,213 @@ describe('registerWorktreeHandlers', () => {
)
})
- it('does not create when the pre-create remote-tracking refresh fails', async () => {
+ 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'
})
+ 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/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(
handlers['worktrees:create'](null, {
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/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts
index 6243138327e..df5b310fcef 100644
--- a/src/main/native-chat/transcript-read-cache.test.ts
+++ b/src/main/native-chat/transcript-read-cache.test.ts
@@ -17,6 +17,7 @@ vi.mock('./transcript-reader', async (importOriginal) => {
}
})
+import { isTextBlock } from '../../shared/native-chat-types'
import {
clearNativeChatTranscriptCache,
readNativeChatTranscriptCached
@@ -89,4 +90,62 @@ describe('readNativeChatTranscriptCached', () => {
const result = await readNativeChatTranscriptCached('claude', 'absent')
expect('error' in result && result.error).toBeTruthy()
})
+
+ // Why: two worktrees can present the SAME (agent, sessionId) via different
+ // transcript files — e.g. the same session resumed into a second worktree,
+ // which writes a new transcript file. Keying the cache by sessionId let one
+ // worktree's cached parse be served to the other whenever their file mtimes
+ // coincided, leaking A's chat transcript into C's panel (#7326).
+ it('never serves one file’s parse for a different file that shares a sessionId', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-xwt-'))
+ tempRoots.push(root)
+ const fileA = join(root, 'worktree-a.jsonl')
+ const fileC = join(root, 'worktree-c.jsonl')
+ await writeFile(
+ fileA,
+ jsonLines([
+ {
+ type: 'user',
+ uuid: 'a0',
+ timestamp: '2026-06-01T10:00:00.000Z',
+ message: { role: 'user', content: 'from-worktree-A' }
+ }
+ ])
+ )
+ await writeFile(
+ fileC,
+ jsonLines([
+ {
+ type: 'user',
+ uuid: 'c0',
+ timestamp: '2026-06-01T10:00:00.000Z',
+ message: { role: 'user', content: 'from-worktree-C' }
+ }
+ ])
+ )
+ // Force IDENTICAL mtimes so a sessionId-only key's mtime guard cannot rescue
+ // the collision — this is the intermittent, activity-driven case.
+ const when = new Date('2026-06-01T10:00:00.000Z')
+ await utimes(fileA, when, when)
+ await utimes(fileC, when, when)
+
+ const readText = (result: Awaited>): string =>
+ 'messages' in result
+ ? result.messages
+ .flatMap((message) => message.blocks)
+ .filter(isTextBlock)
+ .map((block) => block.text)
+ .join(' ')
+ : ''
+
+ // Same sessionId, different transcript files (worktree A resumed into C).
+ const a = await readNativeChatTranscriptCached('claude', 'shared-session', fileA)
+ const c = await readNativeChatTranscriptCached('claude', 'shared-session', fileC)
+
+ expect(readText(a)).toContain('from-worktree-A')
+ expect(readText(c)).toContain('from-worktree-C')
+ expect(readText(c)).not.toContain('from-worktree-A')
+ // Distinct files must not share a cached parse object.
+ expect(c).not.toBe(a)
+ })
})
diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts
index 89738b15cb4..b62a1a1142a 100644
--- a/src/main/native-chat/transcript-read-cache.ts
+++ b/src/main/native-chat/transcript-read-cache.ts
@@ -4,13 +4,18 @@ import { resolveSessionFilePath } from './session-file-resolver'
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
// Why: both the desktop IPC handler and the runtime RPC handler read the same
-// host-filesystem transcript, so a single process-global cache keyed by
-// agent:sessionId maximizes the hit rate across desktop + every paired
-// web/mobile client. Keying by connection instead would defeat the multi-client
-// case this feature targets and multiply memory by the connection count.
-// The cache stores ONE canonical, unwindowed parse; windowing and per-surface
-// truncation stay in the callers so the same parse is reused across all `limit`
-// values and every client kind.
+// host-filesystem transcript, so a single process-global cache keyed by the
+// RESOLVED transcript file path maximizes the hit rate across desktop + every
+// paired web/mobile client (all clients of one session resolve the same path
+// against this runtime's home). Keying by connection instead would defeat the
+// multi-client case this feature targets and multiply memory by the connection
+// count. The key is the resolved file path, NOT `agent:sessionId`: two panes can
+// share one sessionId yet resolve to DIFFERENT files (the same session resumed
+// into a second worktree, which writes a new transcript file), and a
+// sessionId-only key let one worktree's cached parse be served to another when
+// their file mtimes momentarily coincided (#7326). The cache stores ONE
+// canonical, unwindowed parse; windowing and per-surface truncation stay in the
+// callers so the same parse is reused across all `limit` values and every client kind.
type CachedTranscript = {
result: ReadTranscriptResult
@@ -40,8 +45,8 @@ function setCached(key: string, value: CachedTranscript): void {
}
}
-function cacheKey(agent: AgentType, sessionId: string): string {
- return `${agent}:${sessionId}`
+function cacheKey(agent: AgentType, filePath: string): string {
+ return `${agent}:${filePath}`
}
async function fileMtimeMs(filePath: string): Promise {
@@ -68,7 +73,7 @@ export async function readNativeChatTranscriptCached(
return { error: `No transcript found for ${agent} session ${sessionId}` }
}
- const key = cacheKey(agent, sessionId)
+ const key = cacheKey(agent, filePath)
const mtimeMs = await fileMtimeMs(filePath)
const cached = cache.get(key)
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
@@ -84,7 +89,7 @@ export async function readNativeChatTranscriptCached(
return result
}
-/** Test-only: drop the per-session transcript cache between runs. */
+/** Test-only: drop the transcript parse cache between runs. */
export function clearNativeChatTranscriptCache(): void {
cache.clear()
}
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 8ecb3e0e56d..1448f59d946 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', () => {
@@ -100,6 +101,7 @@ describe('LocalPtyProvider', () => {
let exitCb: ((info: { exitCode: number }) => void) | undefined
let origShell: string | undefined
let origPowerlevelWizardDisable: string | undefined
+ let origHistFile: string | undefined
let origPlatform: PropertyDescriptor | undefined
beforeEach(() => {
@@ -107,8 +109,11 @@ describe('LocalPtyProvider', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
origShell = process.env.SHELL
origPowerlevelWizardDisable = process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
+ origHistFile = process.env.HISTFILE
process.env.SHELL = '/bin/zsh'
delete process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
+ // injectHistoryEnv preserves an inherited HISTFILE, so clear it for hermetic history assertions.
+ delete process.env.HISTFILE
existsSyncMock.mockReturnValue(true)
statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 })
@@ -162,6 +167,11 @@ describe('LocalPtyProvider', () => {
} else {
process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD = origPowerlevelWizardDisable
}
+ if (origHistFile === undefined) {
+ delete process.env.HISTFILE
+ } else {
+ process.env.HISTFILE = origHistFile
+ }
})
describe('spawn', () => {
@@ -213,6 +223,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 e3e597e9aee..a1c40fd3305 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({
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/terminal-cwd-realpath.test.ts b/src/main/pty/terminal-cwd-realpath.test.ts
new file mode 100644
index 00000000000..47110648b19
--- /dev/null
+++ b/src/main/pty/terminal-cwd-realpath.test.ts
@@ -0,0 +1,43 @@
+import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterAll, describe, expect, it } from 'vitest'
+import {
+ canonicalizeLocalTerminalPath,
+ localTerminalCwdCanonicalizer
+} from './terminal-cwd-realpath'
+
+describe('canonicalizeLocalTerminalPath', () => {
+ const root = mkdtempSync(join(tmpdir(), 'orca-cwd-realpath-'))
+
+ afterAll(() => {
+ rmSync(root, { recursive: true, force: true })
+ })
+
+ it('resolves symlinks to their target', () => {
+ const outside = join(root, 'outside')
+ const worktree = join(root, 'worktree')
+ mkdirSync(outside)
+ mkdirSync(worktree)
+ const link = join(worktree, 'escape')
+ symlinkSync(outside, link, 'dir')
+ expect(canonicalizeLocalTerminalPath(link)).toBe(canonicalizeLocalTerminalPath(outside))
+ })
+
+ it('returns null for nonexistent paths', () => {
+ expect(canonicalizeLocalTerminalPath(join(root, 'does-not-exist'))).toBeNull()
+ })
+
+ it('skips WSL UNC paths instead of touching the 9P share', () => {
+ expect(canonicalizeLocalTerminalPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toBeNull()
+ expect(canonicalizeLocalTerminalPath('//wsl$/Ubuntu/home/jin/repo')).toBeNull()
+ })
+})
+
+describe('localTerminalCwdCanonicalizer', () => {
+ it('is disabled for SSH connections and enabled locally', () => {
+ expect(localTerminalCwdCanonicalizer('ssh-1')).toBeUndefined()
+ expect(localTerminalCwdCanonicalizer(null)).toBe(canonicalizeLocalTerminalPath)
+ expect(localTerminalCwdCanonicalizer(undefined)).toBe(canonicalizeLocalTerminalPath)
+ })
+})
diff --git a/src/main/pty/terminal-cwd-realpath.ts b/src/main/pty/terminal-cwd-realpath.ts
new file mode 100644
index 00000000000..f9131d9f01a
--- /dev/null
+++ b/src/main/pty/terminal-cwd-realpath.ts
@@ -0,0 +1,30 @@
+import { realpathSync } from 'node:fs'
+import { isWslUncPath } from '../../shared/wsl-paths'
+
+// Why: terminal startup cwd containment is string-based; realpath closes the
+// symlink escape for local paths. Only for local worktrees — SSH/remote paths
+// are not resolvable on this filesystem.
+export function canonicalizeLocalTerminalPath(targetPath: string): string | null {
+ // Why: realpath over the WSL 9P share is unreliable and can block the main
+ // process while the distro boots; keep the string containment check only.
+ if (isWslUncPath(targetPath)) {
+ return null
+ }
+ try {
+ return realpathSync.native(targetPath)
+ } catch {
+ try {
+ // Why: realpathSync.native can fail on network/UNC mounts where the
+ // JS implementation still succeeds (same fallback as git repo scanning).
+ return realpathSync(targetPath)
+ } catch {
+ return null
+ }
+ }
+}
+
+export function localTerminalCwdCanonicalizer(
+ connectionId: string | null | undefined
+): ((path: string) => string | null) | undefined {
+ return connectionId ? undefined : canonicalizeLocalTerminalPath
+}
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/service.test.ts b/src/main/rate-limits/service.test.ts
index 5af9abca04f..1b810859ce7 100644
--- a/src/main/rate-limits/service.test.ts
+++ b/src/main/rate-limits/service.test.ts
@@ -333,6 +333,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 +455,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 +560,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 +593,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 +611,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 +639,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 +723,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 +751,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 () => {
diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts
index e3befa61404..840f4e4a788 100644
--- a/src/main/rate-limits/service.ts
+++ b/src/main/rate-limits/service.ts
@@ -103,6 +103,7 @@ 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
@@ -221,6 +222,11 @@ export class RateLimitService {
}
stop(): void {
+ this.abortActiveFetchCycle()
+ this.clearQueuedFetches()
+ this.inactiveClaudeFetching.clear()
+ this.inactiveCodexFetching.clear()
+ this.resolveAndClearFetchIdleWaiters()
this.stopTimer()
this.clearDeferredStartupRefresh()
this.detachWindowListeners?.()
@@ -369,30 +375,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 +397,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 +455,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 +477,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 +670,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 +684,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 +720,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 +743,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 +770,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 +793,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 +840,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
@@ -850,9 +964,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
@@ -898,18 +1018,24 @@ export class RateLimitService {
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()
])
+ if (signal.aborted) {
+ return
+ }
+
const claude =
claudeResult.status === 'fulfilled'
? claudeResult.value
@@ -979,6 +1105,9 @@ export class RateLimitService {
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 =
@@ -1013,7 +1142,10 @@ export class RateLimitService {
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 +1165,8 @@ export class RateLimitService {
? Promise.resolve(missingWslCodexHome)
: fetchCodexRateLimits({
codexHomePath,
- allowPtyFallback: this.shouldAllowCodexPtyFallback()
+ allowPtyFallback: this.shouldAllowCodexPtyFallback(),
+ signal
})
).catch(
(err): ProviderRateLimits => ({
@@ -1046,6 +1179,10 @@ export class RateLimitService {
})
)
+ if (signal.aborted) {
+ return
+ }
+
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
const shouldApplyCodex =
@@ -1059,9 +1196,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 +1218,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 +1231,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/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts
index 514594eb64c..fce2a5e6d59 100644
--- a/src/main/runtime/fetch-remote-cache.test.ts
+++ b/src/main/runtime/fetch-remote-cache.test.ts
@@ -36,6 +36,12 @@ function exactBaseRefreshOptions(cwd: string): {
return { cwd, timeout: 60_000, useConfiguredSshCommandForNetwork: true }
}
+// Why (STA-1292): the broad create-path fetch must carry a timeout so a Windows
+// credential-manager GUI hang can't wedge worktree creation forever.
+function fullRemoteFetchOptions(cwd: string): { cwd: string; timeout: number } {
+ return { cwd, timeout: 60_000 }
+}
+
function mockFetchResults(results: (Promise | unknown)[]): void {
let fetchIndex = 0
gitExecFileAsyncMock.mockImplementation((argv: string[]) => {
@@ -299,7 +305,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
exactBaseRefreshOptions('/repo/h')
],
- [['fetch', 'origin'], { cwd: '/repo/h' }]
+ [['fetch', 'origin'], fullRemoteFetchOptions('/repo/h')]
])
})
@@ -341,7 +347,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
([argv]) => Array.isArray(argv) && argv[0] === 'fetch'
)
expect(fetchCalls).toEqual([
- [['fetch', 'origin'], { cwd: '/repo/i' }],
+ [['fetch', 'origin'], fullRemoteFetchOptions('/repo/i')],
[
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
exactBaseRefreshOptions('/repo/i')
@@ -387,7 +393,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
([argv]) => Array.isArray(argv) && argv[0] === 'fetch'
)
expect(fetchCalls).toEqual([
- [['fetch', 'origin'], { cwd: '/repo/i-fail' }],
+ [['fetch', 'origin'], fullRemoteFetchOptions('/repo/i-fail')],
[
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
exactBaseRefreshOptions('/repo/i-fail')
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 700d8a34379..77cbe5f9da4 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -177,6 +177,9 @@ const {
listGitHubAssignableUsersMock,
detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgentsMock,
+ markCodexProjectTrustedMock,
+ markCopilotFolderTrustedMock,
+ markCursorWorkspaceTrustedMock,
listGitLabMergeRequestsMock,
listGitLabWorkItemsMock,
listGitLabIssuesMock,
@@ -268,6 +271,9 @@ const {
listGitHubAssignableUsersMock: vi.fn(),
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
detectRemoteAgentsMock: vi.fn(),
+ markCodexProjectTrustedMock: vi.fn(),
+ markCopilotFolderTrustedMock: vi.fn(),
+ markCursorWorkspaceTrustedMock: vi.fn(),
listGitLabMergeRequestsMock: vi.fn(),
listGitLabWorkItemsMock: vi.fn(),
listGitLabIssuesMock: vi.fn(),
@@ -334,6 +340,12 @@ vi.mock('../ipc/preflight', () => ({
detectRemoteAgents: detectRemoteAgentsMock
}))
+vi.mock('../agent-trust-presets', () => ({
+ markCodexProjectTrusted: markCodexProjectTrustedMock,
+ markCopilotFolderTrusted: markCopilotFolderTrustedMock,
+ markCursorWorkspaceTrusted: markCursorWorkspaceTrustedMock
+}))
+
vi.mock('../hooks', () => ({
buildPosixRunnerScript: (script: string) => `#!/usr/bin/env bash\nset -e\n${script}\n`,
buildWindowsRunnerScript: (script: string) => `@echo off\r\n${script}\r\n`,
@@ -642,6 +654,9 @@ function resetRuntimeTestMocks(): void {
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
detectRemoteAgentsMock.mockReset()
detectRemoteAgentsMock.mockResolvedValue([])
+ markCodexProjectTrustedMock.mockReset()
+ markCopilotFolderTrustedMock.mockReset()
+ markCursorWorkspaceTrustedMock.mockReset()
listGitLabMergeRequestsMock.mockReset()
listGitLabMergeRequestsMock.mockResolvedValue({ items: [] })
listGitLabWorkItemsMock.mockReset()
@@ -797,6 +812,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',
@@ -2356,10 +2381,22 @@ describe('OrcaRuntimeService', () => {
}
})
- it('does not create runtime local worktrees when remote-tracking base refresh fails', async () => {
+ 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)
- computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-refresh-fails')
- ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/cli-refresh-fails')
+ const createdWorktree = {
+ path: '/tmp/workspaces/cli-refresh-fails',
+ head: 'base-sha',
+ branch: 'cli-refresh-fails',
+ 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({})
+ vi.mocked(listWorktrees).mockResolvedValue([createdWorktree])
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
@@ -2367,7 +2404,10 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
return { stdout: '/tmp/repo/.git\n', stderr: '' }
}
- 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') {
@@ -2381,6 +2421,167 @@ describe('OrcaRuntimeService', () => {
repoSelector: 'id:repo-1',
name: 'cli-refresh-fails'
})
+ ).resolves.toBeDefined()
+
+ 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()
+ }
+ })
+
+ it('does not create a runtime local worktree when the refresh fails and no local base ref exists', async () => {
+ const runtime = new OrcaRuntimeService(store)
+ computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-refresh-no-local')
+ ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/cli-refresh-no-local')
+ 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: '' }
+ }
+ // No local remote-tracking base ref -> nothing to fall back on.
+ if (args[0] === 'rev-parse' && args[1] === '--verify') {
+ throw new Error('missing ref')
+ }
+ if (args[0] === 'fetch') {
+ throw new Error('network unavailable')
+ }
+ return { stdout: '', stderr: '' }
+ })
+ try {
+ await expect(
+ runtime.createManagedWorktree({
+ repoSelector: 'id:repo-1',
+ name: 'cli-refresh-no-local'
+ })
).rejects.toThrow(
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
)
@@ -3080,6 +3281,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: '' }
}
@@ -3184,6 +3388,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: '' }
}
@@ -3337,6 +3544,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: '' }
}
@@ -3447,6 +3657,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: '' }
}
@@ -3549,6 +3762,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: '' }
}
@@ -3705,6 +3921,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: '' }
}
@@ -4780,6 +4999,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: '' }
}
@@ -4820,7 +5042,10 @@ describe('OrcaRuntimeService', () => {
['rev-parse', '--path-format=absolute', '--git-common-dir'],
wslGitOptions
)
- expect(asyncGitSpy).toHaveBeenCalledWith(['fetch', 'origin'], wslGitOptions)
+ expect(asyncGitSpy).toHaveBeenCalledWith(['fetch', 'origin'], {
+ ...wslGitOptions,
+ timeout: 60_000
+ })
expect(syncGitSpy).toHaveBeenCalledWith(
['rev-list', '--left-right', '--count', 'HEAD...origin/main'],
{ cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu' }
@@ -7631,6 +7856,204 @@ describe('OrcaRuntimeService', () => {
})
})
+ it('applies Settings agent defaults to bare agent command terminal creates', async () => {
+ const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
+ const runtimeStore = {
+ ...store,
+ getSettings: () => ({
+ ...store.getSettings(),
+ disabledTuiAgents: [],
+ agentCmdOverrides: {},
+ agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
+ agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } }
+ })
+ }
+ const runtime = new OrcaRuntimeService(runtimeStore)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
+ command: 'codex',
+ title: 'worker'
+ })
+
+ const spawnCall = spawn.mock.calls[0]?.[0] as
+ | { command?: string; env?: Record }
+ | undefined
+ expect(spawnCall?.command).toBe("codex '--dangerously-bypass-approvals-and-sandbox'")
+ expect(spawnCall?.env).toMatchObject({
+ CODEX_PROFILE: 'captured',
+ ORCA_WORKTREE_ID: TEST_WORKTREE_ID
+ })
+ expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toMatch(UUID_RE)
+ expect(markCodexProjectTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH)
+ expect(markCodexProjectTrustedMock.mock.invocationCallOrder[0]).toBeLessThan(
+ spawn.mock.invocationCallOrder[0]!
+ )
+ })
+
+ it('matches canonical bare agent commands when a command override is configured', async () => {
+ const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
+ const runtimeStore = {
+ ...store,
+ getSettings: () => ({
+ ...store.getSettings(),
+ disabledTuiAgents: [],
+ agentCmdOverrides: { codex: 'codex --profile work' },
+ agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
+ agentDefaultEnv: {}
+ })
+ }
+ const runtime = new OrcaRuntimeService(runtimeStore)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
+ command: 'codex'
+ })
+
+ const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined
+ expect(spawnCall?.command).toBe(
+ "codex --profile work '--dangerously-bypass-approvals-and-sandbox'"
+ )
+ })
+
+ it('keeps non-bare agent command terminal creates unchanged', async () => {
+ const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
+ const runtimeStore = {
+ ...store,
+ getSettings: () => ({
+ ...store.getSettings(),
+ disabledTuiAgents: [],
+ agentCmdOverrides: {},
+ agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
+ agentDefaultEnv: {}
+ })
+ }
+ const runtime = new OrcaRuntimeService(runtimeStore)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
+ command: 'codex exec summarize'
+ })
+
+ const spawnCall = spawn.mock.calls[0]?.[0] as
+ | { command?: string; env?: Record }
+ | undefined
+ expect(spawnCall?.command).toBe('codex exec summarize')
+ expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toBeUndefined()
+ })
+
+ it('keeps disabled bare agent command terminal creates unchanged', async () => {
+ const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
+ const runtimeStore = {
+ ...store,
+ getSettings: () => ({
+ ...store.getSettings(),
+ disabledTuiAgents: ['codex' as const],
+ agentCmdOverrides: {},
+ agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
+ agentDefaultEnv: {}
+ })
+ }
+ const runtime = new OrcaRuntimeService(runtimeStore)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
+ command: 'codex'
+ })
+
+ const spawnCall = spawn.mock.calls[0]?.[0] as
+ | { command?: string; env?: Record }
+ | undefined
+ expect(spawnCall?.command).toBe('codex')
+ expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toBeUndefined()
+ })
+
+ it('sends Settings agent defaults through renderer-backed bare agent terminal creates', async () => {
+ const runtimeStore = {
+ ...store,
+ getSettings: () => ({
+ ...store.getSettings(),
+ disabledTuiAgents: [],
+ agentCmdOverrides: {},
+ agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
+ agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } }
+ })
+ }
+ const runtime = new OrcaRuntimeService(runtimeStore)
+
+ const webContents = { send: vi.fn() }
+ webContents.send.mockImplementation((_channel: string, payload: { requestId: string }) => {
+ runtime.syncWindowGraph(1, {
+ tabs: [],
+ leaves: [
+ {
+ tabId: 'tab-renderer',
+ worktreeId: TEST_WORKTREE_ID,
+ leafId: 'pane:1',
+ paneRuntimeId: 1,
+ ptyId: 'pty-renderer',
+ paneTitle: null
+ }
+ ]
+ })
+ ipcMain.emit(
+ 'terminal:tabCreateReply',
+ { sender: webContents },
+ { requestId: payload.requestId, tabId: 'tab-renderer', title: 'Codex' }
+ )
+ })
+ runtime.attachWindow(1)
+ runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
+ electronMocks.BrowserWindow.fromId.mockReturnValue({
+ isDestroyed: () => false,
+ webContents
+ })
+
+ await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
+ command: 'codex',
+ rendererBacked: true
+ })
+
+ expect(webContents.send).toHaveBeenCalledWith(
+ 'terminal:requestTabCreate',
+ expect.objectContaining({
+ worktreeId: TEST_WORKTREE_ID,
+ command: "codex '--dangerously-bypass-approvals-and-sandbox'",
+ env: { CODEX_PROFILE: 'captured' },
+ launchAgent: 'codex',
+ launchConfig: {
+ agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
+ agentArgs: '--dangerously-bypass-approvals-and-sandbox',
+ agentEnv: { CODEX_PROFILE: 'captured' }
+ }
+ })
+ )
+ expect(markCodexProjectTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH)
+ expect(markCodexProjectTrustedMock.mock.invocationCallOrder[0]).toBeLessThan(
+ webContents.send.mock.invocationCallOrder[0]!
+ )
+ })
+
it('injects runtime hook receiver env into terminal sessions', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-hooked' })
const runtime = new OrcaRuntimeService(store, undefined, {
@@ -7846,7 +8269,7 @@ describe('OrcaRuntimeService', () => {
expect(compoundClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
expect(compoundClaude.env?.TMUX).toBeUndefined()
- expect(normalAgent.command).toBe('codex')
+ expect(normalAgent.command).toBe("codex '--dangerously-bypass-approvals-and-sandbox'")
expect(normalAgent.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
expect(normalAgent.env?.TMUX).toBeUndefined()
})
@@ -8205,7 +8628,7 @@ describe('OrcaRuntimeService', () => {
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
- command: 'codex',
+ command: "codex '--dangerously-bypass-approvals-and-sandbox'",
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID
})
@@ -8274,7 +8697,7 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({
requestId: expect.any(String),
worktreeId: TEST_WORKTREE_ID,
- command: 'codex',
+ command: "codex '--dangerously-bypass-approvals-and-sandbox'",
title: 'Renderer Terminal'
})
)
@@ -22719,6 +23142,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: '' }
}
@@ -22812,6 +23238,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: '' }
}
@@ -22922,6 +23351,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: '' }
}
@@ -23603,6 +24035,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 52de680ef6d..9af6786caca 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -58,6 +58,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 {
@@ -198,7 +200,11 @@ import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../shared/tui-agent-launch-defaults'
-import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
+import {
+ getTuiAgentLaunchCommand,
+ isTuiAgent,
+ TUI_AGENT_CONFIG
+} from '../../shared/tui-agent-config'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
import {
@@ -214,6 +220,7 @@ import {
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
+import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
import { isWslUncPath } from '../../shared/wsl-paths'
import {
folderWorkspaceKey,
@@ -588,7 +595,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,
@@ -969,6 +977,32 @@ type RuntimePtyWorktreeRecord = {
waitBlockedAt: number | null
}
+type TerminalCreateOptions = {
+ command?: string
+ claudeAgentTeamsSourceCommand?: string
+ cwd?: string
+ env?: Record
+ launchConfig?: WorktreeStartupLaunch['launchConfig']
+ launchToken?: string
+ launchAgent?: TuiAgent
+ startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
+ telemetry?: WorktreeStartupLaunch['telemetry']
+ title?: string
+ focus?: boolean
+ rendererBacked?: boolean
+ activate?: boolean
+ presentation?: RuntimeTerminalPresentation
+ tabId?: string
+ leafId?: string
+ sessionId?: string
+ persistHostSessionBinding?: boolean
+ // Why: the headless mobile-session create publishes its own authoritative
+ // snapshot (with the correct target group) right after spawn. Skip the
+ // intermediate pty-backed publish so the new tab doesn't briefly flash in
+ // the wrong (active) group before the corrected snapshot lands.
+ deferMobileSessionPublish?: boolean
+}
+
type PtyForegroundAgentRefresh = {
promise: Promise
startedAfterTitleObservation: number
@@ -985,6 +1019,44 @@ function copySleepingAgentLaunchConfig(
}
}
+function normalizeAgentLaunchCommandForMatch(command: string): string {
+ return command.trim().replace(/\s+/g, ' ')
+}
+
+function resolveBareAgentLaunchCommand(args: {
+ command: string | undefined
+ settings: {
+ agentCmdOverrides?: Partial> | null
+ disabledTuiAgents?: Iterable | null
+ }
+ platform: NodeJS.Platform
+ isRemote: boolean
+}): TuiAgent | null {
+ const command = args.command ? normalizeAgentLaunchCommandForMatch(args.command) : ''
+ if (!command) {
+ return null
+ }
+
+ const cmdOverrides = args.settings.agentCmdOverrides ?? {}
+ for (const agent of Object.keys(TUI_AGENT_CONFIG) as TuiAgent[]) {
+ if (!isTuiAgentEnabled(agent, args.settings.disabledTuiAgents)) {
+ continue
+ }
+ const override = cmdOverrides[agent]?.trim()
+ const defaultLaunchCommand = getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[agent], args.platform, {
+ isRemote: args.isRemote
+ })
+ const launchCommands = override ? [defaultLaunchCommand, override] : [defaultLaunchCommand]
+ if (
+ launchCommands.some((candidate) => command === normalizeAgentLaunchCommandForMatch(candidate))
+ ) {
+ return agent
+ }
+ }
+
+ return null
+}
+
function inferCapturedClaudeAgentTeamsMode(
launchConfig: SleepingAgentLaunchConfig | undefined,
command: string | undefined,
@@ -1946,6 +2018,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()
@@ -13530,12 +13622,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
@@ -13666,38 +13788,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) {
- 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 {
@@ -14607,7 +14752,13 @@ export class OrcaRuntimeService {
}
const promise = this.enqueueRemoteFetch(key, () =>
- gitExecFileAsync(['fetch', remote], { cwd: repoPath, ...gitOptions })
+ gitExecFileAsync(['fetch', remote], {
+ cwd: repoPath,
+ ...gitOptions,
+ // Why: cap the create-path base-ref fetch so a stuck first-auth on
+ // Windows (GCM prompt) fails fast instead of hanging creation (STA-1292).
+ timeout: REMOTE_FETCH_TIMEOUT_MS
+ })
.then((): RemoteFetchResult => {
// Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection
// would make the freshness cache lie about the last known remote state.
@@ -14666,7 +14817,7 @@ export class OrcaRuntimeService {
// Why: exact remote-base refresh is the network gate for worktree
// creation, so honor repo SSH routing and bound custom wrappers.
useConfiguredSshCommandForNetwork: true,
- timeout: 60_000
+ timeout: REMOTE_FETCH_TIMEOUT_MS
}
)
.then((): RemoteFetchResult => {
@@ -16045,33 +16196,71 @@ export class OrcaRuntimeService {
return { handle, tabId: leaf.tabId, title }
}
+ private async resolveAgentTerminalCreateOptions(
+ workspace: TerminalWorkspaceLaunchScope,
+ opts: TerminalCreateOptions
+ ): Promise {
+ // Why: raw shell commands like `codex exec` must remain user-authored shell.
+ // Only unmanaged, repo-backed, bare agent launches get Settings defaults.
+ if (
+ !opts.command ||
+ opts.env ||
+ opts.launchConfig ||
+ opts.launchAgent ||
+ opts.startupCommandDelivery ||
+ opts.claudeAgentTeamsSourceCommand ||
+ !workspace.repo ||
+ !this.store
+ ) {
+ return opts
+ }
+
+ const settings = this.store.getSettings()
+ const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
+ const isRemote = repoIsRemote(workspace.repo)
+ const agent = resolveBareAgentLaunchCommand({
+ command: opts.command,
+ settings,
+ platform,
+ isRemote
+ })
+ if (!agent) {
+ return opts
+ }
+
+ const startupPlan = buildAgentStartupPlan({
+ agent,
+ prompt: '',
+ cmdOverrides: settings.agentCmdOverrides ?? {},
+ agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
+ agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
+ platform,
+ isRemote,
+ allowEmptyPromptLaunch: true
+ })
+ if (!startupPlan) {
+ return opts
+ }
+
+ if (workspace.connectionId) {
+ await this.markRemoteWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path)
+ } else {
+ this.markLocalWorkspaceTrustedForAgent(agent, workspace.path)
+ }
+
+ return {
+ ...opts,
+ command: startupPlan.launchCommand,
+ ...(startupPlan.env ? { env: startupPlan.env } : {}),
+ launchConfig: startupPlan.launchConfig,
+ launchAgent: agent,
+ startupCommandDelivery: startupPlan.startupCommandDelivery
+ }
+ }
+
async createTerminal(
worktreeSelector?: string,
- opts: {
- command?: string
- claudeAgentTeamsSourceCommand?: string
- cwd?: string
- env?: Record
- launchConfig?: WorktreeStartupLaunch['launchConfig']
- launchToken?: string
- launchAgent?: TuiAgent
- startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
- telemetry?: WorktreeStartupLaunch['telemetry']
- title?: string
- focus?: boolean
- rendererBacked?: boolean
- activate?: boolean
- presentation?: RuntimeTerminalPresentation
- tabId?: string
- leafId?: string
- sessionId?: string
- persistHostSessionBinding?: boolean
- // Why: the headless mobile-session create publishes its own authoritative
- // snapshot (with the correct target group) right after spawn. Skip the
- // intermediate pty-backed publish so the new tab doesn't briefly flash in
- // the wrong (active) group before the corrected snapshot lands.
- deferMobileSessionPublish?: boolean
- } = {}
+ opts: TerminalCreateOptions = {}
): Promise {
const presentation = resolveTerminalPresentation(opts)
const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true
@@ -16094,7 +16283,9 @@ export class OrcaRuntimeService {
throw new Error('runtime_unavailable')
}
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
- const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path
+ const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts)
+ const cwd =
+ this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd) ?? workspace.path
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
// Why: mint tabId in main before spawn so paneKey is known at PTY env
// build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys
@@ -16102,25 +16293,27 @@ export class OrcaRuntimeService {
// hook payload arrives with an empty paneKey and the renderer cannot
// attribute the event. Use a stable UUID leaf because hooks reject the
// legacy numeric pane keys after the pane-id migration.
- const hintedTabId = opts.tabId?.trim()
+ const hintedTabId = launchOpts.tabId?.trim()
const canAdoptPaneIdentity =
hintedTabId !== undefined &&
isValidHostTerminalTabId(hintedTabId) &&
- opts.leafId !== undefined &&
- isTerminalLeafId(opts.leafId)
+ launchOpts.leafId !== undefined &&
+ isTerminalLeafId(launchOpts.leafId)
const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
- const leafId = canAdoptPaneIdentity ? (opts.leafId as string) : randomUUID()
+ const leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID()
const paneKey = makePaneKey(tabId, leafId)
- const launchToken = opts.launchConfig ? (opts.launchToken ?? randomUUID()) : undefined
+ const launchToken = launchOpts.launchConfig
+ ? (launchOpts.launchToken ?? randomUUID())
+ : undefined
const baseEnv = {
- ...opts.env,
+ ...launchOpts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
const claudeAgentTeamsSourceCommand =
- opts.claudeAgentTeamsSourceCommand?.trim() || opts.command?.trim() || undefined
+ launchOpts.claudeAgentTeamsSourceCommand?.trim() || launchOpts.command?.trim() || undefined
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
- opts.launchConfig,
+ launchOpts.launchConfig,
claudeAgentTeamsSourceCommand,
claudeAgentTeamsMode
)
@@ -16145,25 +16338,25 @@ export class OrcaRuntimeService {
const sequencedStartupCommand =
agentTeamsPlan &&
claudeAgentTeamsSourceCommand &&
- opts.command &&
- claudeAgentTeamsSourceCommand !== opts.command
+ launchOpts.command &&
+ claudeAgentTeamsSourceCommand !== launchOpts.command
? agentTeamsPlan.command
: undefined
const effectiveLaunchConfig =
- opts.launchConfig && agentTeamsPlan
+ launchOpts.launchConfig && agentTeamsPlan
? {
- ...opts.launchConfig,
- agentCommand: opts.launchConfig.agentCommand
+ ...launchOpts.launchConfig,
+ agentCommand: launchOpts.launchConfig.agentCommand
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
- ? addClaudeTeammateModeInProcess(opts.launchConfig.agentCommand)
- : addClaudeTeammateModeAuto(opts.launchConfig.agentCommand)
+ ? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand)
+ : addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand)
: agentTeamsPlan.command,
agentEnv: {
- ...opts.launchConfig.agentEnv,
+ ...launchOpts.launchConfig.agentEnv,
...agentTeamsPlan.env
}
}
- : opts.launchConfig
+ : launchOpts.launchConfig
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
// Claude Agent Teams runs. Preserve the direct Claude command separately
// so the wrapper can exec the teammate-mode variant after setup completes.
@@ -16183,29 +16376,31 @@ export class OrcaRuntimeService {
cols: 120,
rows: 40,
cwd,
- command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command),
+ command: sequencedStartupCommand
+ ? launchOpts.command
+ : (agentTeamsPlan?.command ?? launchOpts.command),
commandDelivery: 'provider',
- startupCommandDelivery: opts.startupCommandDelivery,
+ startupCommandDelivery: launchOpts.startupCommandDelivery,
env,
envToDelete: agentTeamsPlan?.envToDelete,
- telemetry: opts.telemetry,
+ telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId,
leafId,
- ...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
- ...(opts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
+ ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
+ ...(launchOpts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
})
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
this.registerPty(result.id, workspace.id, workspace.connectionId)
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
- if (opts.title) {
+ if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
- pty.title = opts.title
+ pty.title = launchOpts.title
pty.titleUpdatedAt = observedAt
- this.setPtyManagementTitleFromObservedTitle(pty, opts.title, observedAt)
+ this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
@@ -16216,14 +16411,14 @@ export class OrcaRuntimeService {
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
- pty.launchAgent = opts.launchAgent ?? null
+ pty.launchAgent = launchOpts.launchAgent ?? null
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
- if (pty && opts.deferMobileSessionPublish !== true) {
+ if (pty && launchOpts.deferMobileSessionPublish !== true) {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
tabId,
leafId,
- title: opts.title ?? null,
+ title: launchOpts.title ?? null,
activate: presentation === 'focused',
// Why: explicit background presentation may carry legacy activate
// metadata from an already-owned renderer pane; don't select it on mobile.
@@ -16241,11 +16436,11 @@ export class OrcaRuntimeService {
// already baked into the PTY env — keeps paneKey hook attribution intact.
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
- title: opts.title ?? null,
+ title: launchOpts.title ?? null,
...(cwd !== workspace.path ? { cwd } : {}),
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
- ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
+ ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
tabId,
@@ -16265,7 +16460,7 @@ export class OrcaRuntimeService {
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
- title: opts.title ?? null,
+ title: launchOpts.title ?? null,
surface,
...(warning ? { warning } : {})
}
@@ -16278,8 +16473,13 @@ export class OrcaRuntimeService {
const workspace = worktreeSelector
? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
: null
+ const launchOpts = workspace
+ ? await this.resolveAgentTerminalCreateOptions(workspace, opts)
+ : opts
const worktreeId = workspace?.id
- const cwd = workspace ? resolveTerminalStartupCwd(workspace.path, opts.cwd) : opts.cwd
+ const cwd = workspace
+ ? this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd)
+ : launchOpts.cwd
const requestId = randomUUID()
// Why: terminal creation is a renderer-side Zustand store operation (like
@@ -16303,21 +16503,21 @@ export class OrcaRuntimeService {
if (r.error) {
reject(new Error(r.error))
} else {
- resolve({ tabId: r.tabId!, title: r.title ?? opts.title ?? '' })
+ resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' })
}
}
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
- command: opts.command,
+ command: launchOpts.command,
cwd,
- ...(opts.env ? { env: opts.env } : {}),
- ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
- ...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
- ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
- startupCommandDelivery: opts.startupCommandDelivery,
- title: opts.title,
+ ...(launchOpts.env ? { env: launchOpts.env } : {}),
+ ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}),
+ ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}),
+ ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
+ startupCommandDelivery: launchOpts.startupCommandDelivery,
+ title: launchOpts.title,
activate: presentation === 'focused',
...(presentation ? { presentation } : {})
})
@@ -16422,7 +16622,7 @@ export class OrcaRuntimeService {
this.assertGraphReady()
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const worktreeId = workspace.id
- const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd)
+ const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd)
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
let afterDesktopTabId: string | undefined
if (opts.afterTabId) {
@@ -16626,7 +16826,7 @@ export class OrcaRuntimeService {
} = {}
): Promise {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
- const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd)
+ const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd)
// Why: SshPtyProvider treats sessionId as a relay reattach request. Only
// synthesize local serve ids; SSH fresh terminals must call pty.spawn.
const stableSessionId =
@@ -17495,6 +17695,17 @@ export class OrcaRuntimeService {
}
}
+ // Why: every terminal-creation path must apply the same symlink-aware cwd
+ // guard; keep the canonicalizer wiring in one place.
+ private resolveGuardedWorkspaceTerminalCwd(
+ workspace: Pick,
+ requestedCwd?: string | null
+ ): string | undefined {
+ return resolveTerminalStartupCwd(workspace.path, requestedCwd, {
+ canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId)
+ })
+ }
+
private async resolveTerminalWorkspaceLaunchScope(
selector: string
): Promise {
@@ -23003,6 +23214,10 @@ const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000
// clicks and successive coordinator dispatches feel snappy, while still being
// short enough that a genuinely-changed remote is observed on the next action.
const FETCH_FRESHNESS_MS = 30_000
+// Why: bound create-path remote fetches so a Windows credential-manager GUI hang
+// (STA-1292) can't wedge worktree creation forever; parity with the exact-base
+// refresh sibling's timeout.
+const REMOTE_FETCH_TIMEOUT_MS = 60_000
const REMOTE_FETCH_CACHE_MAX = 512
const DRIFT_PROBE_SUBJECT_LIMIT = 5
diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts
index f40d27077a8..28bc9714db2 100644
--- a/src/main/ssh/ssh-connection.test.ts
+++ b/src/main/ssh/ssh-connection.test.ts
@@ -22,6 +22,7 @@ type MockSshClient = {
_sock: Socket | undefined
lastExecCommand?: string
lastConnectConfig?: unknown
+ exec: (cmd: string, cb: (err: Error | undefined, channel: unknown) => void) => void
}
let clientInstances: MockSshClient[] = []
@@ -707,6 +708,139 @@ describe('SshConnection', () => {
}
})
+ it('retries a session-limit-refused exec open and succeeds on a later attempt', async () => {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+ const channel = { close: vi.fn() }
+ const execMock = vi
+ .fn<(cmd: string, cb: (err: Error | undefined, ch: unknown) => void) => void>()
+ .mockImplementationOnce((_cmd, cb) => {
+ cb(
+ Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 }),
+ undefined
+ )
+ })
+ .mockImplementation((_cmd, cb) => cb(undefined, channel))
+ clientInstances[0].exec = execMock as never
+
+ await expect(conn.exec('printf ready')).resolves.toBe(channel)
+ expect(execMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('surfaces the session-limit error once open retries are exhausted', async () => {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+ const refusal = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
+ reason: 2
+ })
+ const execMock = vi
+ .fn<(cmd: string, cb: (err: Error | undefined, ch: unknown) => void) => void>()
+ .mockImplementation((_cmd, cb) => cb(refusal, undefined))
+ clientInstances[0].exec = execMock as never
+
+ await expect(conn.exec('printf ready')).rejects.toBe(refusal)
+ expect(execMock).toHaveBeenCalledTimes(4)
+ })
+
+ it('does not retry non-session-limit exec open failures', async () => {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+ const failure = new Error('Not connected')
+ const execMock = vi
+ .fn<(cmd: string, cb: (err: Error | undefined, ch: unknown) => void) => void>()
+ .mockImplementation((_cmd, cb) => cb(failure, undefined))
+ clientInstances[0].exec = execMock as never
+
+ await expect(conn.exec('printf ready')).rejects.toBe(failure)
+ expect(execMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('bounds an aborted exec to the close grace when ssh2 never invokes the open callback', async () => {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+ execBehavior = 'pending'
+ const controller = new AbortController()
+
+ vi.useFakeTimers()
+ try {
+ const outcomePromise = conn
+ .exec('printf ready', { signal: controller.signal })
+ .then(() => 'opened')
+ .catch((error: Error) => error.name)
+
+ controller.abort()
+ // Why: a hung socket must not pin the aborted caller for the full 30s
+ // connect timeout — the abort settles at the 5s grace bound instead.
+ await vi.advanceTimersByTimeAsync(5_000)
+
+ await expect(outcomePromise).resolves.toBe('AbortError')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('rejects without waiting out the backoff when aborted during a session-limit retry delay', async () => {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+ const controller = new AbortController()
+ const refusal = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
+ reason: 2
+ })
+ const execMock = vi
+ .fn<(cmd: string, cb: (err: Error | undefined, ch: unknown) => void) => void>()
+ .mockImplementation((_cmd, cb) => cb(refusal, undefined))
+ clientInstances[0].exec = execMock as never
+
+ vi.useFakeTimers()
+ try {
+ const outcomePromise = conn
+ .exec('printf ready', { signal: controller.signal })
+ .then(() => 'opened')
+ .catch((error: Error) => error.name)
+
+ // Flush microtasks so the first refused attempt lands in the backoff.
+ await vi.advanceTimersByTimeAsync(0)
+ controller.abort()
+
+ // No timer advance: the abort alone must release the backoff delay.
+ await expect(outcomePromise).resolves.toBe('AbortError')
+ expect(execMock).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('settles an abort during channel open only after the late channel closes', async () => {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+ execBehavior = 'pending'
+ const controller = new AbortController()
+ const lateChannel = Object.assign(new EventEmitter(), {
+ close: vi.fn(),
+ resume: vi.fn(),
+ stderr: { resume: vi.fn() }
+ })
+
+ const outcomePromise = conn
+ .exec('printf ready', { signal: controller.signal })
+ .then(() => 'opened')
+ .catch((error: Error) => error.name)
+
+ await Promise.resolve()
+ controller.abort()
+ pendingExecCallback?.(undefined, lateChannel)
+
+ // Why: the sshd session slot is freed only when the channel finishes
+ // closing — settling before 'close' lets the next open race the close.
+ const early = await Promise.race([outcomePromise, Promise.resolve('pending')])
+ expect(early).toBe('pending')
+ expect(lateChannel.close).toHaveBeenCalledTimes(1)
+ expect(lateChannel.resume).toHaveBeenCalled()
+
+ lateChannel.emit('close')
+ await expect(outcomePromise).resolves.toBe('AbortError')
+ })
+
it('times out when ssh2 never opens an SFTP channel', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts
index 528f03c4c58..eb2e54909c9 100644
--- a/src/main/ssh/ssh-connection.ts
+++ b/src/main/ssh/ssh-connection.ts
@@ -35,12 +35,25 @@ import {
type SshConnectionCallbacks
} from './ssh-connection-utils'
import type { RemoteHostPlatform } from './ssh-remote-platform'
+import { isSshSessionLimitError } from './ssh-session-limit-error'
export type { SshConnectionCallbacks } from './ssh-connection-utils'
type SshRemoteFileOptions = {
hostPlatform?: RemoteHostPlatform
}
+// Upper bound on waiting, after an abort, for the in-flight open callback or
+// for an aborted late-opened channel to finish closing before rejecting
+// anyway. Normal opens and closes complete in one network round-trip.
+const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000
+
+// Why: on session-limited servers (MaxSessions), a channel open can be refused
+// transiently — e.g. our next CHANNEL_OPEN reaching sshd a few microseconds
+// before it finishes processing the previous channel's close. A refused open
+// never started the command, so retrying is always safe.
+const SESSION_LIMIT_OPEN_RETRIES = 4
+const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150
+
function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfig | null {
if (!config) {
return null
@@ -132,10 +145,14 @@ export class SshConnection {
}
const client = this.client
const remoteCommand = options?.wrapCommand === false ? cmd : wrapRemoteCommandForPosixShell(cmd)
- return this.waitForSshCallback(
- 'SSH exec channel timed out',
- (callback) => client.exec(remoteCommand, callback),
- (channel) => channel.close(),
+ return this.openSessionChannelWithRetry(
+ () =>
+ this.waitForSshCallback(
+ 'SSH exec channel timed out',
+ (callback) => client.exec(remoteCommand, callback),
+ (channel) => channel.close(),
+ options?.signal
+ ),
options?.signal
)
}
@@ -148,13 +165,50 @@ export class SshConnection {
throw new Error('Not connected')
}
const client = this.client
- return this.waitForSshCallback(
- 'SSH SFTP channel timed out',
- (callback) => client.sftp(callback),
- (sftp) => sftp.end()
+ return this.openSessionChannelWithRetry(() =>
+ this.waitForSshCallback(
+ 'SSH SFTP channel timed out',
+ (callback) => client.sftp(callback),
+ (sftp) => sftp.end()
+ )
)
}
+ private async openSessionChannelWithRetry(
+ open: () => Promise,
+ signal?: AbortSignal
+ ): Promise {
+ let lastError: unknown
+ for (let attempt = 0; attempt < SESSION_LIMIT_OPEN_RETRIES; attempt++) {
+ if (attempt > 0) {
+ // Why: an abort must release the backoff immediately, not after it.
+ if (!signal?.aborted) {
+ await new Promise((resolve) => {
+ const onDelayDone = (): void => {
+ clearTimeout(delayTimer)
+ signal?.removeEventListener('abort', onDelayDone)
+ resolve()
+ }
+ const delayTimer = setTimeout(onDelayDone, SESSION_LIMIT_OPEN_RETRY_DELAY_MS)
+ signal?.addEventListener('abort', onDelayDone, { once: true })
+ })
+ }
+ if (signal?.aborted) {
+ throw createSshOperationAbortError()
+ }
+ }
+ try {
+ return await open()
+ } catch (err) {
+ if (!isSshSessionLimitError(err)) {
+ throw err
+ }
+ lastError = err
+ }
+ }
+ throw lastError
+ }
+
private waitForSshCallback(
timeoutMessage: string,
register: (callback: (error: Error | undefined, value: T) => void) => void,
@@ -163,23 +217,66 @@ export class SshConnection {
): Promise {
return new Promise((resolve, reject) => {
let settled = false
+ // Why: rejecting the instant the signal aborts lets the caller proceed
+ // while the in-flight channel open completes in the background and holds
+ // a server-side session slot (MaxSessions). Mark the abort and settle
+ // from the open callback, after the late resource has been closed.
+ let abortRequested = false
+ let abortDeadlineTimer: NodeJS.Timeout | undefined
const cleanup = (): void => {
clearTimeout(timer)
+ clearTimeout(abortDeadlineTimer)
signal?.removeEventListener('abort', onAbort)
}
const onAbort = (): void => {
- if (settled) {
- return
- }
- settled = true
- cleanup()
- reject(createSshOperationAbortError())
+ abortRequested = true
+ // Why: a hung socket may never invoke the open callback — bound the
+ // aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS.
+ abortDeadlineTimer = setTimeout(() => {
+ settled = true
+ cleanup()
+ reject(createSshOperationAbortError())
+ }, ABORTED_CHANNEL_CLOSE_GRACE_MS)
}
const timer = setTimeout(() => {
settled = true
- signal?.removeEventListener('abort', onAbort)
- reject(new Error(timeoutMessage))
+ cleanup()
+ reject(abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage))
}, CONNECT_TIMEOUT_MS)
+ const rejectAfterClose = (value: T): void => {
+ const abortError = createSshOperationAbortError()
+ const emitter = value as Partial & {
+ resume?: () => void
+ stderr?: { resume?: () => void }
+ }
+ let finished = false
+ const done = (): void => {
+ if (finished) {
+ return
+ }
+ finished = true
+ clearTimeout(closeGraceTimer)
+ reject(abortError)
+ }
+ // Why: bounded — a remote that never confirms the close must not hang
+ // the aborted operation forever.
+ const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS)
+ if (typeof emitter.once === 'function') {
+ emitter.once('close', done)
+ }
+ // Why: ssh2 withholds the 'close' event until the channel's streams
+ // are drained; nobody else will ever read this discarded channel.
+ emitter.resume?.()
+ emitter.stderr?.resume?.()
+ try {
+ cleanupLateValue?.(value)
+ } catch {
+ /* best effort */
+ }
+ if (typeof emitter.once !== 'function') {
+ done()
+ }
+ }
const finish = (error: Error | undefined, value?: T): void => {
if (settled) {
// Why: ssh2 can invoke the open callback after our timeout has
@@ -196,6 +293,14 @@ export class SshConnection {
}
settled = true
cleanup()
+ if (abortRequested) {
+ if (!error && value !== undefined) {
+ rejectAfterClose(value)
+ } else {
+ reject(createSshOperationAbortError())
+ }
+ return
+ }
if (error) {
reject(error)
return
@@ -203,7 +308,9 @@ export class SshConnection {
resolve(value as T)
}
if (signal?.aborted) {
- onAbort()
+ // No open is in flight yet, so failing fast leaks nothing.
+ cleanup()
+ reject(createSshOperationAbortError())
return
}
signal?.addEventListener('abort', onAbort, { once: true })
diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts
index dd55f559d9c..cb5477091d6 100644
--- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts
+++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts
@@ -286,8 +286,22 @@ describe('execCommand', () => {
await Promise.resolve()
controller.abort()
- await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' })
+ // Why: sshd frees the MaxSessions slot only when the channel finishes
+ // closing, so abort must request close and settle from the 'close' event —
+ // settling immediately lets the sequential fallback race the closing
+ // channel and get refused.
expect(channel.close).toHaveBeenCalledOnce()
+ const settledEarly = await Promise.race([
+ commandPromise.then(
+ () => 'settled',
+ () => 'settled'
+ ),
+ Promise.resolve('pending')
+ ])
+ expect(settledEarly).toBe('pending')
+ channel.emit('close', 0)
+
+ await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' })
expect(channel.listenerCount('error')).toBe(0)
expect(channel.listenerCount('data')).toBe(0)
expect(channel.listenerCount('close')).toBe(0)
@@ -313,8 +327,13 @@ describe('execCommand', () => {
controller.abort()
resolveExec(channel)
- await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' })
+ await Promise.resolve()
+ await Promise.resolve()
expect(channel.close).toHaveBeenCalledOnce()
+ // Nonzero exit from the killed command must still surface as AbortError.
+ channel.emit('close', 1)
+
+ await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' })
expect(channel.listenerCount('error')).toBe(0)
expect(channel.listenerCount('data')).toBe(0)
expect(channel.listenerCount('close')).toBe(0)
diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts
index e4729014a6c..1b1cdd27074 100644
--- a/src/main/ssh/ssh-relay-deploy-helpers.ts
+++ b/src/main/ssh/ssh-relay-deploy-helpers.ts
@@ -275,11 +275,16 @@ export async function execCommand(
fn(val as never)
}
const fail = (err: Error): void => {
- settle(reject, err)
+ settle(reject, abortRequested ? createSshOperationAbortError() : err)
}
+ // Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE
+ // completes. Settling on abort before the channel actually closes lets the
+ // concurrent-bootstrap sequential fallback reissue an exec while the slot
+ // is still held, so it gets refused again. Close and settle from onClose.
+ let abortRequested = false
const onAbort = (): void => {
+ abortRequested = true
channel.close()
- settle(reject, createSshOperationAbortError())
}
const onStdoutData = (data: Buffer): void => {
stdout += data.toString('utf-8')
@@ -288,7 +293,9 @@ export async function execCommand(
stderr += data.toString('utf-8')
}
const onClose = (code: number): void => {
- if (code !== 0) {
+ if (abortRequested) {
+ settle(reject, createSshOperationAbortError())
+ } else if (code !== 0) {
// Why: on the system-ssh transport channel.stderr carries local OpenSSH
// client noise; preferring it masks the real failure in stdout (2>&1).
const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n')
@@ -299,7 +306,12 @@ export async function execCommand(
}
const timeout = setTimeout(() => {
channel.close()
- settle(reject, new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`))
+ settle(
+ reject,
+ abortRequested
+ ? createSshOperationAbortError()
+ : new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`)
+ )
}, timeoutMs)
// Why: remote reboot tears down exec channels with stream errors. Without
diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts
index 0c7b2b2fcef..2f0bf0ca28f 100644
--- a/src/main/ssh/ssh-relay-session.ts
+++ b/src/main/ssh/ssh-relay-session.ts
@@ -67,10 +67,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
@@ -84,6 +86,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
@@ -194,6 +203,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
}
@@ -223,6 +245,7 @@ export class SshRelaySession {
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
+ remoteHome,
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
relayDir: remoteRelayDir,
nodePath,
@@ -350,6 +373,7 @@ export class SshRelaySession {
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
+ remoteHome,
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
relayDir: remoteRelayDir,
nodePath,
@@ -536,7 +560,19 @@ export class SshRelaySession {
return false
}
- await this.installRemoteOrcaCliShim()
+ try {
+ await this.installRemoteOrcaCliShim()
+ } catch (error) {
+ // Why: the remote `orca` CLI shim is a convenience bridge. On session-
+ // limited remotes (MaxSessions=1) the relay bridge holds the only slot,
+ // so this raw-connection install can fail — that must not fail the
+ // whole connection, matching the managed-hook install above.
+ console.warn(
+ `[ssh-relay-session] remote orca CLI shim install failed for ${this.targetId}: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ )
+ }
if (shouldContinue && !shouldContinue()) {
return false
}
diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts
index 23ea9097540..b3cad3c0ecc 100644
--- a/src/main/ssh/ssh-remote-node-resolution.test.ts
+++ b/src/main/ssh/ssh-remote-node-resolution.test.ts
@@ -331,6 +331,25 @@ describe('resolveRemoteNodePath', () => {
).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,
+ // so without re-raising it here the resolver would launder cancellation
+ // into a fatal "Node.js not found" and defeat the sequential fallback.
+ const controller = new AbortController()
+ const abortError = Object.assign(new Error('SSH operation was cancelled'), {
+ name: 'AbortError'
+ })
+ execCommandMock.mockImplementation(() => {
+ controller.abort()
+ return Promise.reject(abortError)
+ })
+
+ await expect(
+ resolveRemoteNodePath(conn, undefined, { signal: controller.signal })
+ ).rejects.toMatchObject({ name: 'AbortError' })
+ })
+
it('throws when every candidate across both strategies is below the minimum', async () => {
execCommandMock
.mockResolvedValueOnce('/old/node\n') // path probe
diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts
index 4117ddf97ef..e4bb3181b79 100644
--- a/src/main/ssh/ssh-remote-node-resolution.ts
+++ b/src/main/ssh/ssh-remote-node-resolution.ts
@@ -1,5 +1,5 @@
import type { SshConnection } from './ssh-connection'
-import { shellEscape } from './ssh-connection-utils'
+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'
@@ -47,7 +47,7 @@ export async function resolveRemoteNodePath(
return loginShellPath
}
- throwNodeNotFound()
+ throwNodeNotFound(options)
}
// Probe the on-disk install directories of every common Node version manager
@@ -245,10 +245,18 @@ async function resolveRemoteWindowsNodePath(
// Fall through to the shared error below.
}
- throwNodeNotFound()
+ throwNodeNotFound(options)
}
-function throwNodeNotFound(): never {
+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.
+ 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.'
diff --git a/src/main/ssh/ssh-session-limit-error.test.ts b/src/main/ssh/ssh-session-limit-error.test.ts
index a2aaac5e4b3..65608fd9a6e 100644
--- a/src/main/ssh/ssh-session-limit-error.test.ts
+++ b/src/main/ssh/ssh-session-limit-error.test.ts
@@ -10,6 +10,16 @@ describe('isSshSessionLimitError', () => {
).toBe(true)
})
+ it('matches the OpenSSH MaxSessions rejection (SSH2_OPEN_CONNECT_FAILED + "open failed")', () => {
+ // Why: stock OpenSSH sshd refuses session channels over MaxSessions with
+ // reason 2, not resource-shortage — observed against OpenSSH 9.2.
+ expect(
+ isSshSessionLimitError(
+ Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 })
+ )
+ ).toBe(true)
+ })
+
it('matches OpenSSH mux and MaxSessions failures', () => {
expect(
isSshSessionLimitError(
@@ -29,11 +39,6 @@ describe('isSshSessionLimitError', () => {
Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 1 })
)
).toBe(false)
- expect(
- isSshSessionLimitError(
- Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 })
- )
- ).toBe(false)
expect(
isSshSessionLimitError(
Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 3 })
diff --git a/src/main/ssh/ssh-session-limit-error.ts b/src/main/ssh/ssh-session-limit-error.ts
index aed848525b8..d3f1c120b11 100644
--- a/src/main/ssh/ssh-session-limit-error.ts
+++ b/src/main/ssh/ssh-session-limit-error.ts
@@ -4,8 +4,11 @@ export function isSshSessionLimitError(err: unknown): boolean {
}
const reason = (err as { reason?: unknown }).reason
const message = err.message.toLowerCase()
+ // Why: OpenSSH rejects session channels over MaxSessions with
+ // SSH2_OPEN_CONNECT_FAILED (2) and the literal description "open failed";
+ // reason 4 (resource shortage) covers other server implementations.
if (
- reason === 4 &&
+ (reason === 2 || reason === 4) &&
(message.includes('channel open failure') || message.includes('open failed'))
) {
return true
diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts
index cd1faf35c60..7939af31aae 100644
--- a/src/main/startup/desktop-startup-ordering.test.ts
+++ b/src/main/startup/desktop-startup-ordering.test.ts
@@ -32,4 +32,25 @@ describe('desktop startup ordering', () => {
expect(attachIndex).toBeGreaterThanOrEqual(0)
expect(startIndex).toBeGreaterThan(attachIndex)
})
+
+ it('starts the automation scheduler before headless serve reports ready', () => {
+ const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
+ const serveStart = source.indexOf('if (serveOptions) {')
+ const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart)
+ const serveReturn = source.indexOf('return', serveReady)
+ const runtimeRpcStart = source.indexOf('await runtimeRpc.start()', serveStart)
+ const automationStart = source.indexOf('automations.start()', serveStart)
+ const desktopSetWebContents = source.indexOf('automations.setWebContents(window.webContents)')
+ const desktopAutomationStart = source.indexOf('automations.start()', desktopSetWebContents + 1)
+
+ expect(serveStart).toBeGreaterThanOrEqual(0)
+ expect(serveReady).toBeGreaterThan(serveStart)
+ expect(serveReturn).toBeGreaterThan(serveReady)
+ expect(runtimeRpcStart).toBeGreaterThan(serveStart)
+ expect(automationStart).toBeGreaterThan(runtimeRpcStart)
+ expect(automationStart).toBeLessThan(serveReady)
+ expect(automationStart).toBeLessThan(serveReturn)
+ expect(desktopSetWebContents).toBeGreaterThanOrEqual(0)
+ expect(desktopAutomationStart).toBeGreaterThan(desktopSetWebContents)
+ })
})
diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts
index 44475f8cc92..1a30178a4e2 100644
--- a/src/main/window/createMainWindow.ts
+++ b/src/main/window/createMainWindow.ts
@@ -31,6 +31,7 @@ import {
import {
getWindowShortcutActionId,
matchesRecentTabSwitcherChord,
+ nativeZoomCommandMatchesKeybindings,
resolveWindowShortcutAction,
windowShortcutActionCapturesTerminal,
type WindowShortcutAction
@@ -40,7 +41,6 @@ import {
toModifierDoubleTapEvent
} from '../../shared/modifier-double-tap-detector'
import {
- keybindingMatchesAction,
normalizeTerminalShortcutPolicy,
type KeybindingMatchOptions,
type KeybindingOverrides
@@ -67,38 +67,6 @@ function forceRepaint(window: BrowserWindow): void {
}, 32)
}
-function nativeZoomCommandMatchesKeybindings(
- direction: 'in' | 'out',
- platform: NodeJS.Platform,
- keybindings?: KeybindingOverrides,
- options: KeybindingMatchOptions = {}
-): boolean {
- const primary =
- platform === 'darwin' ? { meta: true, control: false } : { meta: false, control: true }
- const actionId = direction === 'in' ? 'zoom.in' : 'zoom.out'
- const candidates =
- direction === 'in'
- ? [
- { key: '=', code: 'Equal', shift: false },
- { key: '+', code: 'Equal', shift: true },
- { key: 'Add', code: 'NumpadAdd', shift: false }
- ]
- : [
- { key: '-', code: 'Minus', shift: false },
- { key: 'Subtract', code: 'NumpadSubtract', shift: false }
- ]
-
- return candidates.some((candidate) =>
- keybindingMatchesAction(
- actionId,
- { ...primary, alt: false, ...candidate },
- platform,
- keybindings,
- options
- )
- )
-}
-
function isMacAppPasteInput(input: Electron.Input): boolean {
return (
process.platform === 'darwin' &&
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/renderer/src/App.tsx b/src/renderer/src/App.tsx
index aaca51f6877..6bd713e0159 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -888,19 +888,28 @@ function App(): React.JSX.Element {
// active one) so a cold start that restored a remote workspace doesn't
// hide local repos. The sidebar "All hosts" scope then shows them all.
await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts())
- await timeRendererStartupStep('fetch-project-groups', () =>
- actions.fetchProjectGroupsForAllHosts()
- )
- await timeRendererStartupStep('fetch-folder-workspaces', () =>
- actions.fetchFolderWorkspacesForAllHosts()
- )
- // Why: both of these fan out `git worktree list` per repo on the main
- // process. Running them concurrently lets the main process share one
+ // Why: project-groups/folder-workspaces read neither the repos store nor
+ // worktrees, so once repos land they can overlap the per-repo
+ // `git worktree list` fan-out (#7225) instead of queuing ahead of it — a
+ // slow remote host's 15s scope RPCs no longer block the scan. folders
+ // still follow project-groups (they read projectGroups); all settle
+ // before the hydrate steps below.
+ const projectScopeChain = (async () => {
+ await timeRendererStartupStep('fetch-project-groups', () =>
+ actions.fetchProjectGroupsForAllHosts()
+ )
+ await timeRendererStartupStep('fetch-folder-workspaces', () =>
+ actions.fetchFolderWorkspacesForAllHosts()
+ )
+ })()
+ // Why: worktrees + lineage both fan out `git worktree list` per repo on
+ // the main process. Running them concurrently lets it share one
// in-flight scan per repo instead of paying the process-spawn fan-out
// twice back-to-back — the dominant renderer-chain cost on Windows
// (issue #7225). Lineage only reads settings + its own slice, so it
// does not depend on the worktrees fetch having landed.
await Promise.all([
+ projectScopeChain,
timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()),
timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage())
])
diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts
index ad2f480bdd9..81135b32cae 100644
--- a/src/renderer/src/app-startup-routing.test.ts
+++ b/src/renderer/src/app-startup-routing.test.ts
@@ -6,9 +6,7 @@ describe('renderer startup runtime routing', () => {
it('loads settings before repo and worktree hydration', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const startupBlockStart = source.indexOf('void (async () => {')
- const startupBlockEnd = source.indexOf(
- "const persistedUI = await timeRendererStartupStep('ui-get'"
- )
+ const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
const settingsIndex = startupBlock.indexOf('actions.fetchSettings()')
@@ -17,6 +15,31 @@ describe('renderer startup runtime routing', () => {
expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()'))
})
+ it('overlaps sidebar scope loads with worktree hydration before session hydration', () => {
+ const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
+ const startupBlockStart = source.indexOf('void (async () => {')
+ const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
+ const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
+
+ const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()')
+ const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {')
+ const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()')
+ const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()')
+ const promiseAllIndex = startupBlock.indexOf('await Promise.all([')
+ const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex)
+ const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()')
+ const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()')
+
+ expect(reposIndex).toBeGreaterThanOrEqual(0)
+ expect(scopeChainIndex).toBeGreaterThan(reposIndex)
+ expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex)
+ expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex)
+ expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex)
+ expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex)
+ expect(worktreesIndex).toBeGreaterThan(promiseAllIndex)
+ expect(lineageIndex).toBeGreaterThan(promiseAllIndex)
+ })
+
it('waits for first-window startup services before terminal reconnect', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const reconnectIndex = source.indexOf('await actions.reconnectPersistedTerminals')
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(' 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/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/pet/PetOverlay.keyframes.test.tsx b/src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx
new file mode 100644
index 00000000000..5e264ff06b6
--- /dev/null
+++ b/src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx
@@ -0,0 +1,107 @@
+// @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 { i18n } from '../../i18n/i18n'
+
+const storeState = vi.hoisted(() => ({
+ agentStatusByPaneKey: {},
+ agentStatusEpoch: 0,
+ retainedAgentsByPaneKey: {},
+ petSize: 180
+}))
+
+vi.mock('../../store', () => ({
+ useAppStore: Object.assign(
+ (selector: (state: typeof storeState) => T): T => {
+ return selector(storeState)
+ },
+ {
+ getState: () => storeState
+ }
+ )
+}))
+
+vi.mock('./usePetUrl', () => ({
+ usePetUrl: () => ({
+ url: 'blob:custom-pet',
+ ready: true,
+ sprite: {
+ frameWidth: 32,
+ frameHeight: 24,
+ columns: 6,
+ rows: 1,
+ sheetWidth: 192,
+ sheetHeight: 24,
+ fps: 6,
+ defaultAnimation: 'idle',
+ animations: {
+ idle: { row: 0, frames: 6 }
+ }
+ },
+ detected: null
+ })
+}))
+
+import { PetOverlay } from './PetOverlay'
+
+function renderPetOverlay(): { container: HTMLDivElement; root: Root } {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ act(() => {
+ root.render( )
+ })
+ return { container, root }
+}
+
+function installLocalStorage(): void {
+ const values = new Map()
+ Object.defineProperty(window, 'localStorage', {
+ configurable: true,
+ value: {
+ clear: () => values.clear(),
+ getItem: (key: string) => values.get(key) ?? null,
+ removeItem: (key: string) => values.delete(key),
+ setItem: (key: string, value: string) => values.set(key, value)
+ }
+ })
+}
+
+describe('PetOverlay sprite keyframes', () => {
+ let root: Root | null = null
+ let container: HTMLDivElement | null = null
+
+ beforeEach(async () => {
+ await i18n.changeLanguage('zh')
+ installLocalStorage()
+ })
+
+ afterEach(async () => {
+ if (root) {
+ act(() => root?.unmount())
+ }
+ container?.remove()
+ root = null
+ container = null
+ await i18n.changeLanguage('en')
+ })
+
+ it('keeps custom spritesheet keyframes valid in translated locales', () => {
+ ;({ container, root } = renderPetOverlay())
+
+ const css = Array.from(container.querySelectorAll('style'))
+ .map((style) => style.textContent ?? '')
+ .join('\n')
+
+ expect(css).toContain('@keyframes pet-bob')
+ expect(css).toContain('transform: translateY(0)')
+ expect(css).toContain('background-position:')
+ expect(css).toContain('to { background-position:')
+ expect(css).not.toContain('变换')
+ expect(css).not.toContain('背景位置')
+ expect(css).not.toContain('到 {')
+ })
+})
diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx
index e55004a4788..89fc81dc6da 100644
--- a/src/renderer/src/components/pet/PetOverlay.tsx
+++ b/src/renderer/src/components/pet/PetOverlay.tsx
@@ -6,7 +6,6 @@ import type { CustomPet } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state'
-import { translate } from '@/i18n/i18n'
type Sprite = NonNullable
@@ -66,15 +65,12 @@ function SpriteFrame({
const startY = -(row * sprite.frameHeight * scale)
const endX = -(frames * sprite.frameWidth * scale)
const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps))
+ // Why: sprite keyframes are runtime CSS, not user-visible copy; translated
+ // CSS keywords make the browser discard the animation.
+ const keyframesCss = `@keyframes pet-${animKeyframesId} { from { background-position: ${startX}px ${startY}px; } to { background-position: ${endX}px ${startY}px; } }`
return (
<>
-
+
-
+
{sprite ? (
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}
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..d5a538b44fd 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
@@ -31,7 +31,7 @@ export function SessionInlineDetails({
}
onResumeInWorktree: () => void
onResumeInNewTab: () => void
- onOpenLog: () => void
+ onOpenLog?: () => void
}): React.JSX.Element {
const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId)
const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId)
@@ -132,20 +132,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}
)
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
index 5ea6bd9d307..0826eed9509 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
@@ -1,14 +1,6 @@
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 { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Badge } from '@/components/ui/badge'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { AgentIcon } from '@/lib/agent-catalog'
@@ -27,6 +19,7 @@ import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import { SessionInlineDetails, SessionTime } 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 {
@@ -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 } : {})
})
@@ -223,101 +217,6 @@ 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, '-')}`
}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
index e7293f15de7..3d2bdde0384 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
@@ -19,6 +19,7 @@ import {
isAiVaultSessionInCurrentWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
+import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions'
import {
extractVaultVirtualRowIndexes,
getVaultStickyHeaderIndexes,
@@ -275,6 +276,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/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.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx
index 61bb6ac3634..6d0121157bb 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx
@@ -47,6 +47,7 @@ const mocks = vi.hoisted(() => {
openBranchDiff: vi.fn(),
createEmptySplitGroup: vi.fn(),
discardRuntimeGitPath: vi.fn(),
+ bulkStageRuntimeGitPaths: vi.fn(),
refreshGitStatusForWorktree: vi.fn(),
requestEditorSaveQuiesce: vi.fn(),
notifyEditorExternalFileChange: vi.fn()
@@ -85,7 +86,8 @@ vi.mock('@/runtime/runtime-git-client', async (importOriginal) => {
const actual = await importOriginal>()
return {
...actual,
- discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath
+ discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath,
+ bulkStageRuntimeGitPaths: mocks.calls.bulkStageRuntimeGitPaths
}
})
@@ -140,6 +142,7 @@ function resetState(overrides: Partial> = {}): void {
vi.clearAllMocks()
mocks.calls.createEmptySplitGroup.mockReturnValue('group-2')
mocks.calls.discardRuntimeGitPath.mockResolvedValue(undefined)
+ mocks.calls.bulkStageRuntimeGitPaths.mockResolvedValue(undefined)
mocks.calls.refreshGitStatusForWorktree.mockResolvedValue(undefined)
mocks.calls.requestEditorSaveQuiesce.mockResolvedValue(undefined)
mocks.state = {
@@ -437,13 +440,76 @@ describe('SourceControl preview row opens', () => {
const row = container.querySelector(
'[data-source-control-path="packages/nested"]'
)
- expect(row?.textContent).toContain('Submodule changes - stage inside submodule')
+ expect(row?.textContent).toContain('Stage inside submodule')
+ expect(
+ row?.querySelector('[title*="cannot stage file changes inside a submodule"]')
+ ).not.toBeNull()
- const stageButton = row?.querySelector(
- 'button[aria-label="Stage these changes inside the submodule"]'
+ expect(row?.querySelector('button[aria-label="Stage"]')).toBeNull()
+ })
+
+ it('does not render a commit-area Stage All primary for submodule worktree-only rows', () => {
+ resetState({
+ gitStatusByWorktree: {
+ [mocks.activeWorktree.id]: [
+ gitEntry({
+ path: 'june-11th-launch',
+ submodule: { commitChanged: false, trackedChanges: true, untrackedChanges: false }
+ })
+ ]
+ }
+ })
+ renderSourceControl()
+
+ const row = container.querySelector(
+ '[data-source-control-path="june-11th-launch"]'
+ )
+ expect(row?.textContent).toContain('Stage inside submodule')
+
+ const stageAllButton = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent?.trim() === 'Stage All'
+ )
+ expect(stageAllButton).toBeUndefined()
+
+ const commitButton = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent?.trim() === 'Commit'
+ )
+ expect(commitButton).toBeDefined()
+ expect(commitButton?.disabled).toBe(true)
+
+ act(() => {
+ commitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ expect(mocks.calls.bulkStageRuntimeGitPaths).not.toHaveBeenCalled()
+ })
+
+ it('keeps the commit-area Stage All primary for changed submodule gitlinks', async () => {
+ resetState({
+ gitStatusByWorktree: {
+ [mocks.activeWorktree.id]: [
+ gitEntry({
+ path: 'packages/nested',
+ submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false }
+ })
+ ]
+ }
+ })
+ renderSourceControl()
+
+ const stageAllButton = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent?.trim() === 'Stage All'
+ )
+ expect(stageAllButton).toBeDefined()
+ expect(stageAllButton?.disabled).toBe(false)
+
+ await act(async () => {
+ stageAllButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ await Promise.resolve()
+ })
+ expect(mocks.calls.bulkStageRuntimeGitPaths).toHaveBeenCalledWith(
+ expect.objectContaining({ worktreeId: mocks.activeWorktree.id, worktreePath: '/repo/wt' }),
+ ['packages/nested']
)
- expect(stageButton).not.toBeNull()
- expect(stageButton?.getAttribute('aria-disabled')).toBe('true')
})
it('passes preview=true when a plain branch row click opens a branch diff tab', () => {
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index 59a44672999..11803eee85e 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -536,8 +536,9 @@ const SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX = 8
const SOURCE_CONTROL_TREE_FILE_PADDING_PX = 20
const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' }
const DEFAULT_COLLAPSED_SECTIONS = ['history'] as const
-const SUBMODULE_WORKTREE_ONLY_LABEL = 'Submodule changes - stage inside submodule'
-const SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP = 'Stage these changes inside the submodule'
+const SUBMODULE_WORKTREE_ONLY_LABEL = 'Stage inside submodule'
+const SUBMODULE_WORKTREE_ONLY_TOOLTIP =
+ 'The parent repo (including Stage All) cannot stage file changes inside a submodule'
const SUBMODULE_LOADING_LABEL = 'Loading submodule changes…'
const SUBMODULE_EMPTY_LABEL = 'No changes in submodule'
const SUBMODULE_ERROR_LABEL = 'Failed to load submodule changes'
@@ -3995,7 +3996,12 @@ function SourceControlInner(): React.JSX.Element {
])
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
- const hasStageableChanges = hasUnstagedChanges
+ const hasStageableChanges = useMemo(
+ () =>
+ grouped.unstaged.some(isStageableStatusEntry) ||
+ grouped.untracked.some(isStageableStatusEntry),
+ [grouped.unstaged, grouped.untracked]
+ )
const hasPartiallyStagedChanges = useMemo(() => {
if (grouped.staged.length === 0 || grouped.unstaged.length === 0) {
return false
@@ -8115,9 +8121,17 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
{dirPath}
)}
- {(conflictLabel || isSubmoduleWorktreeOnly) && (
-
- {conflictLabel ?? SUBMODULE_WORKTREE_ONLY_LABEL}
+ {conflictLabel && (
+
{conflictLabel}
+ )}
+ {isSubmoduleWorktreeOnly && (
+ // Why: parent git can stage a changed gitlink, but not nested
+ // worktree dirtiness. Keep that boundary visible in the row.
+
+ {SUBMODULE_WORKTREE_ONLY_LABEL}
)}
@@ -8176,19 +8190,14 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
}}
/>
)}
- {(canStage || isSubmoduleWorktreeOnly) && (
+ {canStage && (
{
event.stopPropagation()
void onStage(entry.path)
}}
- disabled={isSubmoduleWorktreeOnly}
/>
)}
{canUnstage && (
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(executionHostScope)
+ executionHostScopeRef.current = executionHostScope
+ const currentScanScopeKey = useCallback(
+ () => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`,
+ []
+ )
- const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise => {
- // A scope change during an in-flight scan must not be dropped; queue one more
- // scan so the current scoped view is refreshed after the older scan settles.
- if (refreshInFlightRef.current) {
- pendingRefreshRef.current = true
- pendingForceRef.current ||= args.force === true
- pendingBackgroundRef.current &&= args.background === true
- return
- }
-
- refreshInFlightRef.current = true
- const refreshId = refreshIdRef.current + 1
- refreshIdRef.current = refreshId
- // A manual force scan counts against the throttle so an auto rescan right
- // after the button press doesn't trigger a second full scan.
- if (args.force === true) {
- lastForcedRescanAt = Date.now()
- }
- // Background (refocus) refreshes usually resolve from the main-process
- // cache; suppressing the loading flag avoids a spinner flash on every
- // return to the app.
- if (args.background !== true) {
- setLoading(true)
- }
- setError(null)
- const scopeKey = scopePathsRef.current.join('\n')
- try {
- const result = await window.api.aiVault.listSessions({
- limit: SESSION_LIMIT,
- scopePaths: scopePathsRef.current,
- force: args.force
- })
- if (!mountedRef.current || refreshIdRef.current !== refreshId) {
+ const refresh = useCallback(
+ async (args: AiVaultRefreshArgs = {}): Promise => {
+ // A scope change during an in-flight scan must not be dropped; queue one more
+ // scan so the current scoped view is refreshed after the older scan settles.
+ if (refreshInFlightRef.current) {
+ pendingRefreshRef.current = true
+ pendingForceRef.current ||= args.force === true
+ pendingBackgroundRef.current &&= args.background === true
return
}
- // A cache hit returns the snapshot already on screen; skip the state
- // updates so refocus flips don't force pointless re-renders.
- if (
- lastAppliedScanRef.current?.scopeKey === scopeKey &&
- lastAppliedScanRef.current.scannedAt === result.scannedAt
- ) {
- return
+
+ refreshInFlightRef.current = true
+ const refreshId = refreshIdRef.current + 1
+ refreshIdRef.current = refreshId
+ // A manual force scan counts against the throttle so an auto rescan right
+ // after the button press doesn't trigger a second full scan.
+ if (args.force === true) {
+ lastForcedRescanAt = Date.now()
}
- lastAppliedScanRef.current = { scopeKey, scannedAt: result.scannedAt }
- setScanResult(result)
- setSessions(result.sessions)
- } catch (err) {
- if (mountedRef.current && refreshIdRef.current === refreshId) {
- setError(err instanceof Error ? err.message : String(err))
+ // Background (refocus) refreshes usually resolve from the main-process
+ // cache; suppressing the loading flag avoids a spinner flash on every
+ // return to the app.
+ if (args.background !== true) {
+ setLoading(true)
}
- } finally {
- refreshInFlightRef.current = false
- if (mountedRef.current && refreshIdRef.current === refreshId) {
- setLoading(false)
+ setError(null)
+ const scopeKey = scopePathsRef.current.join('\n')
+ const hostScope = executionHostScopeRef.current
+ const scanKey = `${hostScope}\n${scopeKey}`
+ try {
+ const result = await window.api.aiVault.listSessions({
+ limit: SESSION_LIMIT,
+ scopePaths: scopePathsRef.current,
+ executionHostScope: hostScope,
+ force: args.force
+ })
+ if (!mountedRef.current || refreshIdRef.current !== refreshId) {
+ return
+ }
+ // Why: host/scope changes queue a follow-up scan, but the older result
+ // may resolve first and must not briefly paint the wrong history list.
+ if (scanKey !== currentScanScopeKey()) {
+ return
+ }
+ // A cache hit returns the snapshot already on screen; skip the state
+ // updates so refocus flips don't force pointless re-renders.
+ if (
+ lastAppliedScanRef.current?.scopeKey === scanKey &&
+ lastAppliedScanRef.current.scannedAt === result.scannedAt
+ ) {
+ return
+ }
+ lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt }
+ setScanResult(result)
+ setSessions(result.sessions)
+ } catch (err) {
+ if (
+ mountedRef.current &&
+ refreshIdRef.current === refreshId &&
+ scanKey === currentScanScopeKey()
+ ) {
+ setError(err instanceof Error ? err.message : String(err))
+ }
+ } finally {
+ refreshInFlightRef.current = false
+ if (mountedRef.current && refreshIdRef.current === refreshId) {
+ setLoading(false)
+ }
+ if (pendingRefreshRef.current && mountedRef.current) {
+ pendingRefreshRef.current = false
+ const force = pendingForceRef.current
+ // The queued refresh is background-only if every queued caller was.
+ const background = pendingBackgroundRef.current
+ pendingForceRef.current = false
+ pendingBackgroundRef.current = true
+ void refresh({ force, background })
+ }
}
- if (pendingRefreshRef.current && mountedRef.current) {
- pendingRefreshRef.current = false
- const force = pendingForceRef.current
- // The queued refresh is background-only if every queued caller was.
- const background = pendingBackgroundRef.current
- pendingForceRef.current = false
- pendingBackgroundRef.current = true
- void refresh({ force, background })
- }
- }
- // Deps are intentionally empty: refresh reads changing values through refs
- // and recurses on itself, so its identity must stay stable.
- }, [])
+ // Deps intentionally avoid changing scope values: refresh reads them
+ // through refs and recurses on itself, so its identity must stay stable.
+ },
+ [currentScanScopeKey]
+ )
// Forced rescans triggered by events (refocus, agent-session starts) run
// immediately when the throttle allows, otherwise once as soon as it frees
@@ -162,7 +188,7 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
if (!force) {
requestForcedRescan()
}
- }, [refresh, requestForcedRescan, scopePathsKey])
+ }, [refresh, requestForcedRescan, scanScopeKey])
// Sessions started while the app was backgrounded should appear when the
// user returns, so refocus also bypasses the scan cache (throttled). OS
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
index b8a428cd0c0..eb82d910cb9 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
@@ -1,8 +1,10 @@
import type { Repo, Worktree } from '../../../../shared/types'
import {
canResumeAiVaultSessionOnTarget,
+ getAiVaultResumeWorkspaceExecutionHostId,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
+import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AppState } from '@/store/types'
import { translate } from '@/i18n/i18n'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
@@ -34,6 +36,7 @@ export type AiVaultSessionResumeActions = {
export function resolveAiVaultSessionResumeState(args: {
sessionFilePath: string | null
+ sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
worktreeInfo: AiVaultSessionWorktreeInfo | null
activeWorktreeId: string | null
worktrees: readonly Worktree[]
@@ -56,6 +59,7 @@ export function resolveAiVaultSessionResumeState(args: {
for (const worktreeId of candidateWorktreeIds) {
const targetId = resolveSupportedResumeWorktreeId({
sessionFilePath: args.sessionFilePath,
+ sessionExecutionHostId: args.sessionExecutionHostId,
worktreeId,
targetState
})
@@ -78,6 +82,7 @@ export function resolveAiVaultSessionResumeState(args: {
export function resolveAiVaultSessionResumeActions(args: {
sessionFilePath: string | null
+ sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
worktreeInfo: AiVaultSessionWorktreeInfo | null
activeWorktreeId: string | null
worktrees: readonly Worktree[]
@@ -92,11 +97,13 @@ export function resolveAiVaultSessionResumeActions(args: {
const sessionTargetId = resolveSupportedResumeWorktreeId({
sessionFilePath: args.sessionFilePath,
+ sessionExecutionHostId: args.sessionExecutionHostId,
worktreeId: sessionWorktreeId,
targetState
})
const activeTargetId = resolveSupportedResumeWorktreeId({
sessionFilePath: args.sessionFilePath,
+ sessionExecutionHostId: args.sessionExecutionHostId,
worktreeId:
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
? args.activeWorktreeId
@@ -142,6 +149,7 @@ export function isKnownAiVaultResumeWorkspaceTarget(
function resolveSupportedResumeWorktreeId(args: {
sessionFilePath: string | null
+ sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
worktreeId: string | null
targetState: AiVaultSessionResumeTargetState
}): string | null {
@@ -154,7 +162,18 @@ function resolveSupportedResumeWorktreeId(args: {
}
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId)
- if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
+ const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(
+ args.targetState,
+ args.worktreeId
+ )
+ if (
+ !canResumeAiVaultSessionOnTarget({
+ sessionFilePath: args.sessionFilePath,
+ sessionExecutionHostId: args.sessionExecutionHostId,
+ targetStatus,
+ targetExecutionHostId
+ })
+ ) {
return null
}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts
index b378afe3d42..58687977a33 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
-import type { Worktree } from '../../../../shared/types'
+import type { Repo, Worktree } from '../../../../shared/types'
import {
aiVaultWorktreeCompactPath,
aiVaultWorktreeJumpTooltip,
@@ -15,6 +15,7 @@ import {
const baseSession: AiVaultSession = {
id: 'codex:session-1',
+ executionHostId: 'local',
agent: 'codex',
sessionId: 'session-1',
title: 'Find the pane',
@@ -55,6 +56,19 @@ function makeWorktree(overrides: Partial = {}): Worktree {
return { ...worktree, ...overrides }
}
+function makeRepo(overrides: Partial = {}): Repo {
+ return {
+ id: 'repo-1',
+ path: '/repo/orca',
+ displayName: 'orca',
+ badgeColor: '#737373',
+ addedAt: 1,
+ connectionId: null,
+ executionHostId: 'local',
+ ...overrides
+ }
+}
+
describe('resolveAiVaultSessionWorktreeInfo', () => {
it('marks the selected owning worktree as current', () => {
const worktree = makeWorktree()
@@ -135,6 +149,55 @@ describe('resolveAiVaultSessionWorktreeInfo', () => {
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca'
})
})
+
+ it('uses the session host when multiple worktrees share the same path', () => {
+ const localWorktree = makeWorktree({
+ id: 'repo-local::/srv/orca',
+ repoId: 'repo-local',
+ displayName: 'local',
+ path: '/srv/orca',
+ hostId: 'local'
+ })
+ const sshWorktree = makeWorktree({
+ id: 'repo-ssh::/srv/orca',
+ repoId: 'repo-ssh',
+ displayName: 'ssh',
+ path: '/srv/orca',
+ hostId: 'ssh:target-1'
+ })
+
+ expect(
+ resolveAiVaultSessionWorktreeInfo({
+ session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' },
+ worktrees: [localWorktree, sshWorktree],
+ activeWorktreeId: null
+ })
+ ).toMatchObject({
+ label: 'ssh',
+ worktreeId: sshWorktree.id
+ })
+ })
+
+ it('uses repo host ownership when a legacy worktree lacks host metadata', () => {
+ const worktree = makeWorktree({
+ id: 'repo-ssh::/srv/orca',
+ repoId: 'repo-ssh',
+ displayName: 'ssh',
+ path: '/srv/orca'
+ })
+
+ expect(
+ resolveAiVaultSessionWorktreeInfo({
+ session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' },
+ repos: [makeRepo({ id: 'repo-ssh', connectionId: 'target-1', executionHostId: null })],
+ worktrees: [worktree],
+ activeWorktreeId: null
+ })
+ ).toMatchObject({
+ label: 'ssh',
+ worktreeId: worktree.id
+ })
+ })
})
describe('canJumpToAiVaultSessionWorktree', () => {
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts
index bd7aeef1bea..31222bec1b5 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts
@@ -1,6 +1,12 @@
import { useMemo } from 'react'
import { parseWslUncPath } from '../../../../shared/wsl-paths'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
+import {
+ getRepoExecutionHostId,
+ LOCAL_EXECUTION_HOST_ID,
+ normalizeExecutionHostId,
+ type ExecutionHostId
+} from '../../../../shared/execution-host'
import {
isPathInsideOrEqual,
isRuntimePathAbsolute,
@@ -8,7 +14,7 @@ import {
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
-import type { Worktree } from '../../../../shared/types'
+import type { Repo, Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable'
@@ -23,16 +29,19 @@ export type AiVaultSessionWorktreeInfo = {
type WorktreeCandidate = {
worktree: Worktree
path: string
+ hostId: ExecutionHostId
status: Exclude
source: 'current-path' | 'prior-path'
}
export function resolveAiVaultSessionWorktreeInfo({
session,
+ repos = [],
worktrees,
activeWorktreeId
}: {
session: AiVaultSession
+ repos?: readonly Pick[]
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): AiVaultSessionWorktreeInfo | null {
@@ -40,8 +49,10 @@ export function resolveAiVaultSessionWorktreeInfo({
return null
}
- const candidates = buildWorktreeCandidates(worktrees)
+ const sessionHostId = normalizeExecutionHostId(session.executionHostId)
+ const candidates = buildWorktreeCandidates(worktrees, repos)
.filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!))
+ .filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId)
.sort(compareWorktreeCandidates)
const best = candidates[0]
@@ -85,6 +96,7 @@ export function extractWorktreePathFromSessionTitle(title: string): string | nul
export function resolveAiVaultSessionWorktreeDisplay(args: {
session: AiVaultSession
+ repos?: readonly Pick[]
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): AiVaultSessionWorktreeInfo | null {
@@ -117,10 +129,12 @@ export function resolveAiVaultSessionWorktreeDisplay(args: {
export function useAiVaultSessionWorktreeMap({
sessions,
+ repos = [],
worktrees,
activeWorktreeId
}: {
sessions: readonly AiVaultSession[]
+ repos?: readonly Pick[]
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): ReadonlyMap {
@@ -130,13 +144,14 @@ export function useAiVaultSessionWorktreeMap({
sessions.flatMap((session) => {
const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({
session,
+ repos,
worktrees,
activeWorktreeId
})
return worktreeInfo ? [[session.id, worktreeInfo] as const] : []
})
),
- [activeWorktreeId, sessions, worktrees]
+ [activeWorktreeId, repos, sessions, worktrees]
)
}
@@ -192,13 +207,22 @@ export function aiVaultWorktreeJumpTooltip(
)
}
-function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] {
+function buildWorktreeCandidates(
+ worktrees: readonly Worktree[],
+ repos: readonly Pick[]
+): WorktreeCandidate[] {
const candidates: WorktreeCandidate[] = []
+ const repoById = new Map(repos.map((repo) => [repo.id, repo]))
for (const worktree of worktrees) {
+ const repo = repoById.get(worktree.repoId)
+ const hostId =
+ normalizeExecutionHostId(worktree.hostId) ??
+ (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID)
if (hasUsablePath(worktree.path)) {
candidates.push({
worktree,
path: worktree.path,
+ hostId,
status: worktree.isArchived ? 'archived' : 'active',
source: 'current-path'
})
@@ -211,6 +235,7 @@ function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandid
candidates.push({
worktree,
path: parsed.worktreePath,
+ hostId,
status: worktree.isArchived ? 'archived' : 'active',
source: 'prior-path'
})
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
index 62268e292b6..30a78f5391b 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
@@ -1609,13 +1609,6 @@ function PRCommentActionBadge({
)
}
- if (actionState === 'open') {
- return (
-
- {translate('auto.components.right.sidebar.checks.panel.content.7c1f0a2b11', 'Open')}
-
- )
- }
if (actionState === 'resolved') {
return (
diff --git a/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts b/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts
index dcd72e755a7..6ce3560f6a1 100644
--- a/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts
+++ b/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts
@@ -41,7 +41,6 @@ export type PRCommentPresentationClasses = {
audienceTab: string
audienceTabActive: string
sectionTriageLabel: string
- statusBadgeOpen: string
statusBadgeResolved: string
statusBadgeQueued: string
commentHeaderPrimary: string
@@ -160,8 +159,6 @@ export function getPRCommentPresentationClasses(
'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors',
audienceTabActive: 'bg-muted text-foreground',
sectionTriageLabel: cn('px-3 pt-2', RESOLVED_SECTION_LABEL),
- statusBadgeOpen:
- 'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success',
statusBadgeResolved:
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
statusBadgeQueued:
@@ -211,8 +208,6 @@ export function getPRCommentPresentationClasses(
'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors',
audienceTabActive: 'bg-muted text-foreground shadow-xs',
sectionTriageLabel: cn('px-3 pt-1', RESOLVED_SECTION_LABEL),
- statusBadgeOpen:
- 'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success',
statusBadgeResolved:
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
statusBadgeQueued:
diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts
index 36de9c82648..5609880477a 100644
--- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts
@@ -4,9 +4,15 @@ import type { useRepoById } from '@/store/selectors'
import type { TuiAgent } from '../../../../shared/types'
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
+export type SourceControlAgentScopeNote = {
+ effectiveAgentLabel: string
+ globalAgentLabel: string
+}
+
export type UseSourceControlAgentActionDialogResult = {
handleOpenChange: (nextOpen: boolean) => void
shouldRenderDialog: boolean
+ agentScopeNote: SourceControlAgentScopeNote | null
agentOptions: ReturnType
selectedAgent: TuiAgent | null
hasEnabledAgents: boolean
diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
index 7dca868c749..74e7403cd9d 100644
--- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
+++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAgentCatalog } from '@/lib/agent-catalog'
-import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
+import {
+ pickSourceControlLaunchAgent,
+ resolveSourceControlLaunchAgentScope
+} from '@/lib/source-control-launch-agent-selection'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions'
@@ -39,6 +42,15 @@ export function useSourceControlAgentActionDialog({
}: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult {
const settings = useAppStore((state) => state.settings)
const repo = useRepoById(repoId ?? null)
+ const launchAgentScope = useMemo(
+ () => resolveSourceControlLaunchAgentScope({ settings, repo, actionId }),
+ [actionId, repo, settings]
+ )
+ // Why: when this repo already overrides the global default, default the save
+ // scope to the repo so saving the corrected agent updates that override in
+ // place instead of writing a global default the override would still shadow.
+ const defaultSaveTargetValue =
+ launchAgentScope.overridesGlobalAgent && repoId ? 'repo' : DEFAULT_SAVE_TARGET_VALUE
const ensureDetectedAgents = useAppStore((state) => state.ensureDetectedAgents)
const ensureRemoteDetectedAgents = useAppStore((state) => state.ensureRemoteDetectedAgents)
const [commandTemplate, setCommandTemplate] = useState(
@@ -54,7 +66,7 @@ export function useSourceControlAgentActionDialog({
const [detectedOpenCycle, setDetectedOpenCycle] = useState(null)
const saveTargets = useMemo(() => buildSourceControlAgentSaveTargets(repoId), [repoId])
const [saveLaunchRecipe, setSaveLaunchRecipe] = useState(true)
- const [saveTargetValue, setSaveTargetValue] = useState(DEFAULT_SAVE_TARGET_VALUE)
+ const [saveTargetValue, setSaveTargetValue] = useState(defaultSaveTargetValue)
const disabledAgents = settings?.disabledTuiAgents
const connectionUnavailable = Boolean(worktreeId && connectionId === undefined)
@@ -94,7 +106,7 @@ export function useSourceControlAgentActionDialog({
setAgentArgs(savedAgentArgs ?? '')
setSelectedAgent(savedAgentId ?? null)
setSaveLaunchRecipe(true)
- setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
+ setSaveTargetValue(defaultSaveTargetValue)
let stale = false
void refreshDetectedAgents().then((nextAgents) => {
if (stale || openCycleRef.current !== cycle) {
@@ -116,6 +128,7 @@ export function useSourceControlAgentActionDialog({
stale = true
}
}, [
+ defaultSaveTargetValue,
disabledAgents,
open,
refreshDetectedAgents,
@@ -191,11 +204,11 @@ export function useSourceControlAgentActionDialog({
if (!nextOpen) {
resetDeliveryPlan()
setSaveLaunchRecipe(true)
- setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
+ setSaveTargetValue(defaultSaveTargetValue)
}
onOpenChange(nextOpen)
},
- [onOpenChange, resetDeliveryPlan]
+ [defaultSaveTargetValue, onOpenChange, resetDeliveryPlan]
)
const { autoLaunchPending } = useSavedSourceControlAgentActionAutoStart({
@@ -263,9 +276,23 @@ export function useSourceControlAgentActionDialog({
[resetDeliveryPlan]
)
+ const agentScopeNote = useMemo(() => {
+ if (!launchAgentScope.overridesGlobalAgent) {
+ return null
+ }
+ const catalog = getAgentCatalog()
+ const labelFor = (agentId: TuiAgent | null): string =>
+ catalog.find((entry) => entry.id === agentId)?.label ?? agentId ?? ''
+ return {
+ effectiveAgentLabel: labelFor(launchAgentScope.effectiveAgentId),
+ globalAgentLabel: labelFor(launchAgentScope.globalAgentId)
+ }
+ }, [launchAgentScope])
+
return {
handleOpenChange,
shouldRenderDialog: !autoLaunchPending,
+ agentScopeNote,
agentOptions,
selectedAgent,
hasEnabledAgents,
diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx
index 0bf97d581ea..3e247f88db5 100644
--- a/src/renderer/src/components/settings/AccountsPane.test.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.test.tsx
@@ -54,8 +54,8 @@ describe('AccountsPane', () => {
it('keeps the runtime label inside the localized account copy', () => {
const markup = renderPane(getDefaultSettings('/tmp'))
- expect(markup).toContain('Showing accounts for This device. New accounts are added there.')
- expect(markup).toContain('authenticate with Google for This device. This uses credentials')
+ expect(markup).toContain('Showing accounts for this device. New accounts are added there.')
+ expect(markup).toContain('authenticate with Google for this device. This uses credentials')
expect(markup).not.toContain('ShowingThis device')
expect(markup).not.toContain('forThis device')
})
@@ -65,8 +65,8 @@ describe('AccountsPane', () => {
const markup = renderPane(getDefaultSettings('/tmp'))
- expect(markup).toContain(
- 'Mostrando cuentas para este dispositivo. Las nuevas cuentas se agregan allí.'
+ expect(markup).toMatch(
+ /Mostrando cuentas para [Ee]ste dispositivo\. Las nuevas cuentas se agregan allí\./
)
expect(markup).not.toContain('This device')
})
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx
index 947ec64c228..d50003b5122 100644
--- a/src/renderer/src/components/settings/AccountsPane.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.tsx
@@ -264,6 +264,11 @@ export function AccountsPane({
wslDistros,
wslCapabilitiesLoading
)
+ // Why: host runtime labels are standalone UI labels; interpolated prose needs sentence casing.
+ const accountRuntimeSentenceLabel =
+ accountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows')
+ ? `${accountRuntime.label.charAt(0).toLocaleLowerCase()}${accountRuntime.label.slice(1)}`
+ : accountRuntime.label
const [codexAccounts, setCodexAccounts] = useState({
accounts: [],
@@ -611,7 +616,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.c0a52abfc5',
'Showing accounts for {{value0}}. New accounts are added there.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -693,7 +698,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.e05d0ff737',
'Use your current {{value0}} Claude login.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -703,7 +708,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.3fe7862418',
"No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.",
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
) : (
@@ -863,7 +868,7 @@ export function AccountsPane({
: translate(
'auto.components.settings.AccountsPane.e4a28e8894',
'Codex reported that the {{value0}} login needs a fresh sign-in. Sign in again before starting new Codex sessions.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -877,7 +882,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.c0a52abfc5',
'Showing accounts for {{value0}}. New accounts are added there.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -964,12 +969,12 @@ export function AccountsPane({
? translate(
'auto.components.settings.AccountsPane.fd62f37c24',
'Codex reported this {{value0}} login is out of date.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)
: translate(
'auto.components.settings.AccountsPane.fcc4093fc1',
'Use your current {{value0}} Codex login.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -979,7 +984,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.b4c9450319',
"No managed Codex accounts for {{value0}}. Orca will use that environment's system default Codex login until you add one here.",
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
) : (
@@ -1177,7 +1182,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.c2aee76420',
'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx
index 6c3c06dd3df..0d1c377c45f 100644
--- a/src/renderer/src/components/settings/BrowserProfileRow.tsx
+++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx
@@ -174,8 +174,11 @@ export function BrowserProfileRow({
browser.profiles.length > 1 ? (
- {translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')}
- {browser.label}
+ {translate(
+ 'auto.components.settings.BrowserProfileRow.c5a273a809',
+ 'From {{value0}}',
+ { value0: browser.label }
+ )}
@@ -197,8 +200,11 @@ export function BrowserProfileRow({
key={browser.family}
onSelect={() => void handleImportFromBrowser(browser.family)}
>
- {translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')}
- {browser.label}
+ {translate(
+ 'auto.components.settings.BrowserProfileRow.c5a273a809',
+ 'From {{value0}}',
+ { value0: browser.label }
+ )}
)
)}
diff --git a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx
index 5104ace581f..f73668fa908 100644
--- a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx
+++ b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx
@@ -116,9 +116,9 @@ export function BrowserUseCookieImportStep({
{translate(
'auto.components.settings.BrowserUsePane.112f70adc4',
- 'Last imported from'
+ 'Last imported from {{value0}}',
+ { value0: sourceLabel }
)}
- {sourceLabel}
) : null}
{onConfigureMoreBrowsers ? (
@@ -163,8 +163,11 @@ export function BrowserUseCookieImportStep({
browser.profiles.length > 1 ? (
- {translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')}
- {browser.label}
+ {translate(
+ 'auto.components.settings.BrowserUsePane.5301857d88',
+ 'From {{value0}}',
+ { value0: browser.label }
+ )}
@@ -186,8 +189,11 @@ export function BrowserUseCookieImportStep({
key={browser.family}
onSelect={() => void handleImportFromBrowser(browser.family)}
>
- {translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')}
- {browser.label}
+ {translate(
+ 'auto.components.settings.BrowserUsePane.5301857d88',
+ 'From {{value0}}',
+ { value0: browser.label }
+ )}
)
)}
diff --git a/src/renderer/src/components/settings/GhosttyImportModal.test.ts b/src/renderer/src/components/settings/GhosttyImportModal.test.ts
index 6806f9a2151..67590e710f2 100644
--- a/src/renderer/src/components/settings/GhosttyImportModal.test.ts
+++ b/src/renderer/src/components/settings/GhosttyImportModal.test.ts
@@ -99,6 +99,25 @@ describe('GhosttyImportModal', () => {
expect(onOpenChange).toHaveBeenCalledWith(false)
})
+ it('labels Ghostty line-height imports with the settings display name', () => {
+ const element = GhosttyImportModal({
+ open: true,
+ onOpenChange: () => {},
+ preview: {
+ found: true,
+ configPath: '/Users/alice/.config/ghostty/config',
+ diff: { terminalLineHeight: 1.35 },
+ unsupportedKeys: []
+ },
+ loading: false,
+ onApply: () => {},
+ applied: false
+ })
+
+ expect(containsText(element, 'Line Height')).toBe(true)
+ expect(containsText(element, 'terminalLineHeight')).toBe(false)
+ })
+
it('renders success summary with done button when applied', () => {
const onOpenChange = vi.fn()
const onApply = vi.fn()
diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx
index 2ec4fcd516c..6e7513fc973 100644
--- a/src/renderer/src/components/settings/MobileSettingsPane.tsx
+++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx
@@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries }
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
const ORCA_ANDROID_APK_URL =
- 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk'
+ 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk'
export function MobileSettingsPane(): React.JSX.Element {
const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx
index a44759ff38f..58a735e04dc 100644
--- a/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx
+++ b/src/renderer/src/components/settings/RepositorySourceControlAiActionRows.tsx
@@ -13,6 +13,7 @@ import {
} from '../../../../shared/source-control-ai-actions'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
+import { Button } from '../ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { AgentIcon } from '@/lib/agent-catalog'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
@@ -37,9 +38,11 @@ import {
resolveAgentArgsPlaceholderAgent
} from './repository-source-control-ai-labels'
import { hasOwnActionOverride } from './repository-source-control-ai-draft'
+import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets'
import { translate } from '@/i18n/i18n'
type RepositorySourceControlAiActionRowsProps = {
+ repoId: string
repoAi: RepoSourceControlAiOverrides
source: SourceControlAiSettings
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
@@ -48,9 +51,14 @@ type RepositorySourceControlAiActionRowsProps = {
onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void
onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
onAppendVariable: (actionId: SourceControlActionId, variable: string) => void
+ isSaving: boolean
+ actionDirtyById: Record
+ onActionDiscard: (actionId: SourceControlActionId) => void
+ onActionSave: (actionId: SourceControlActionId) => void
}
export function RepositorySourceControlAiActionRows({
+ repoId,
repoAi,
source,
defaultTuiAgent,
@@ -58,7 +66,11 @@ export function RepositorySourceControlAiActionRows({
onActionAgentChange,
onActionTemplateChange,
onActionAgentArgsChange,
- onAppendVariable
+ onAppendVariable,
+ isSaving,
+ actionDirtyById,
+ onActionDiscard,
+ onActionSave
}: RepositorySourceControlAiActionRowsProps): React.JSX.Element {
return (
@@ -90,8 +102,17 @@ export function RepositorySourceControlAiActionRows({
const agentOptions = getAgentCatalogForAction(actionId, effectiveAgent)
const agentWarningText = getSourceControlActionAgentWarningText(actionId, effectiveAgent)
const agentSupportText = getSourceControlActionAgentSupportText(actionId)
+ const actionDirty = actionDirtyById[actionId]
return (
-
+
@@ -227,6 +248,54 @@ export function RepositorySourceControlAiActionRows({
/>
+ {hasOverride ? (
+
+
+ {actionDirty
+ ? translate(
+ 'auto.components.settings.RepositorySourceControlAiSection.e57dde9d93',
+ 'Unsaved changes'
+ )
+ : translate(
+ 'auto.components.settings.RepositorySourceControlAiSection.ccb07dd027',
+ 'Saved'
+ )}
+
+
+ {actionDirty ? (
+ onActionDiscard(actionId)}
+ disabled={isSaving}
+ >
+ {translate(
+ 'auto.components.settings.RepositorySourceControlAiSection.67b3ff5467',
+ 'Discard'
+ )}
+
+ ) : null}
+ onActionSave(actionId)}
+ disabled={!actionDirty || isSaving}
+ >
+ {isSaving
+ ? translate(
+ 'auto.components.settings.RepositorySourceControlAiSection.57e6e9d4b1',
+ 'Saving...'
+ )
+ : translate(
+ 'auto.components.settings.RepositorySourceControlAiSection.152268c295',
+ 'Save'
+ )}
+
+
+
+ ) : null}
)
})}
diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts
index 9715002a997..1153604715e 100644
--- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts
+++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts
@@ -6,11 +6,16 @@ import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
+import {
+ SOURCE_CONTROL_ACTION_IDS,
+ type SourceControlActionId
+} from '../../../../shared/source-control-ai-actions'
import {
createRepoAiDraftState,
dropRepoLegacyInstructionForAction,
resolveRepoAiDraftState
} from './RepositorySourceControlAiSection'
+import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows'
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
describe('RepositorySourceControlAiSection draft state', () => {
@@ -98,6 +103,53 @@ describe('RepositorySourceControlAiEnablement', () => {
})
})
+describe('RepositorySourceControlAiActionRows', () => {
+ it('renders save controls on dirty repository action recipes', () => {
+ const actionDirtyById = Object.fromEntries(
+ SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure'])
+ ) as Record
+ const source = {
+ enabled: true,
+ agentId: null,
+ selectedModelByAgent: {},
+ selectedThinkingByModel: {},
+ customAgentCommand: '',
+ instructionsByOperation: {}
+ } as SourceControlAiSettings
+
+ const markup = renderToStaticMarkup(
+ React.createElement(RepositorySourceControlAiActionRows, {
+ repoId: 'repo-1',
+ repoAi: {
+ actionOverrides: {
+ fixCommitFailure: {
+ agentId: null,
+ commandInputTemplate: '{basePrompt}\n\nrepo'
+ }
+ }
+ },
+ source,
+ defaultTuiAgent: 'codex',
+ isSaving: false,
+ actionDirtyById,
+ onActionModeChange: () => {},
+ onActionAgentChange: () => {},
+ onActionTemplateChange: () => {},
+ onActionAgentArgsChange: () => {},
+ onAppendVariable: () => {},
+ onActionDiscard: () => {},
+ onActionSave: () => {}
+ })
+ )
+
+ expect(markup).toContain('Commit failure fixes')
+ expect(markup).toContain('Unsaved changes')
+ expect(markup).toContain('Discard')
+ expect(markup).toContain('Save')
+ expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure')
+ })
+})
+
describe('dropRepoLegacyInstructionForAction', () => {
it('prevents legacy text instructions from remigrating after an action override is cleared', () => {
const next = dropRepoLegacyInstructionForAction(
diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx
index 6f5f771379c..5568912edd2 100644
--- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx
+++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx
@@ -1,16 +1,18 @@
import { useMemo, useState } from 'react'
import type React from 'react'
-import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
+import type { Repo, TuiAgent } from '../../../../shared/types'
import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import {
normalizeRepoSourceControlAiOverrides,
- normalizeSourceControlAiSettings,
- resolveSourceControlActionRecipe
+ normalizeSourceControlAiSettings
} from '../../../../shared/source-control-ai'
import { toSourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
-import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
+import {
+ SOURCE_CONTROL_ACTION_IDS,
+ type SourceControlActionId
+} from '../../../../shared/source-control-ai-actions'
import { Button } from '../ui/button'
import { useAppStore } from '../../store'
import { useMountedRef } from '@/hooks/useMountedRef'
@@ -20,18 +22,21 @@ import { RepositorySourceControlAiCustomCommand } from './RepositorySourceContro
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
import { RepositorySourceControlAiHostedReviewDefaults } from './RepositorySourceControlAiHostedReviewDefaults'
import {
+ buildActionScopedRepoAiSave,
createRepoAiDraftState,
dropRepoLegacyInstructionForAction,
hasOwnActionOverride,
normalizeRepoAiDraft,
+ readCompleteRecipeForDraft,
resolveRepoAiDraftState,
+ serializeActionOverride,
serializeRepoAiDraft,
+ setActionOverride,
type RepoAiDraftState
} from './repository-source-control-ai-draft'
import {
ACTION_MODE_INHERIT,
DEFAULT_AGENT_VALUE,
- completeRepoActionRecipe,
readInheritedCommandTemplate
} from './repository-source-control-ai-labels'
import { getSettingOwnershipSummary } from './setting-ownership'
@@ -50,40 +55,6 @@ type RepositorySourceControlAiSectionProps = {
type HostedReviewDefaultKey = keyof NonNullable
-function readCompleteRecipeForDraft(
- current: RepoSourceControlAiOverrides,
- settings: GlobalSettings | null,
- actionId: SourceControlActionId
-): NonNullable<
- NonNullable[SourceControlActionId]
-> {
- const recipe = resolveSourceControlActionRecipe({
- settings,
- repo: { sourceControlAi: current },
- actionId
- })
- return completeRepoActionRecipe(recipe, actionId)
-}
-
-function setActionOverride(
- current: RepoSourceControlAiOverrides,
- actionId: SourceControlActionId,
- recipe: NonNullable<
- NonNullable[SourceControlActionId]
- >
-): RepoSourceControlAiOverrides {
- return dropRepoLegacyInstructionForAction(
- {
- ...current,
- actionOverrides: {
- ...current.actionOverrides,
- [actionId]: recipe
- }
- },
- actionId
- )
-}
-
export function RepositorySourceControlAiSection({
repo,
updateRepo
@@ -130,6 +101,17 @@ export function RepositorySourceControlAiSection({
const draftSerialized = useMemo(() => serializeRepoAiDraft(repoAi), [repoAi])
const isDirty =
resolvedDraftState.repoId !== repo.id || draftSerialized !== resolvedDraftState.baseSerialized
+ const actionDirtyById = useMemo(
+ () =>
+ Object.fromEntries(
+ SOURCE_CONTROL_ACTION_IDS.map((actionId) => [
+ actionId,
+ serializeActionOverride(repoAi, actionId) !==
+ serializeActionOverride(persistedRepoAi, actionId)
+ ])
+ ) as Record,
+ [persistedRepoAi, repoAi]
+ )
const updateDraftRepoAi = (
update: (current: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides
@@ -198,6 +180,50 @@ export function RepositorySourceControlAiSection({
setSaveError(null)
}
+ const discardActionDraft = (actionId: SourceControlActionId): void => {
+ updateDraftRepoAi((current) => {
+ const nextActionOverrides = { ...current.actionOverrides }
+ if (hasOwnActionOverride(persistedRepoAi.actionOverrides, actionId)) {
+ nextActionOverrides[actionId] = persistedRepoAi.actionOverrides?.[actionId]
+ } else {
+ delete nextActionOverrides[actionId]
+ }
+ return dropRepoLegacyInstructionForAction(
+ { ...current, actionOverrides: nextActionOverrides },
+ actionId
+ )
+ })
+ }
+
+ const saveActionDraft = async (actionId: SourceControlActionId): Promise => {
+ if (isSaving || !actionDirtyById[actionId]) {
+ return
+ }
+ // Why: a per-action Save persists only that action's override (its Discard is
+ // action-scoped, so its Save must be too) — never other rows' pending edits.
+ const next = buildActionScopedRepoAiSave(persistedRepoAi, repoAi, actionId)
+ const repoUpdate = toSourceControlAiRepoUpdate(next)
+ setIsSaving(true)
+ setSaveError(null)
+ try {
+ const result = await updateRepo(repo.id, repoUpdate)
+ if (!mountedRef.current) {
+ return
+ }
+ if (result === false) {
+ setSaveError('Failed to save Source Control AI settings.')
+ }
+ } catch {
+ if (mountedRef.current) {
+ setSaveError('Failed to save Source Control AI settings.')
+ }
+ } finally {
+ if (mountedRef.current) {
+ setIsSaving(false)
+ }
+ }
+ }
+
const updateEnablement = (value: boolean | undefined): void => {
updateDraftRepoAi((current) => ({ ...current, enabled: value }))
}
@@ -364,14 +390,19 @@ export function RepositorySourceControlAiSection({
onChange={updateCustomCommand}
/>
void saveActionDraft(actionId)}
/>
void
onTemplateChange: (actionId: SourceControlActionId, value: string) => void
onAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
@@ -57,6 +58,7 @@ export function SourceControlActionRecipeRow({
baseValue,
defaultTuiAgent,
isSavingTemplate,
+ repoOverrideNote,
onAgentChange,
onTemplateChange,
onAgentArgsChange,
@@ -163,50 +165,53 @@ export function SourceControlActionRecipeRow({
/>
-
-
- {templateDirty
- ? translate(
- 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
- 'Unsaved changes'
- )
- : translate(
- 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
- 'Saved'
- )}
-
-
- {templateDirty ? (
-
onDiscard(actionId)}
- disabled={isSavingTemplate}
- >
- {translate(
- 'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
- 'Discard'
- )}
-
- ) : null}
-
onSave(actionId)}
- disabled={!templateDirty || isSavingTemplate}
- >
- {isSavingTemplate
+
+ {repoOverrideNote}
+
+
+ {templateDirty
? translate(
- 'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
- 'Saving...'
+ 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
+ 'Unsaved changes'
)
: translate(
- 'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
- 'Save'
+ 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
+ 'Saved'
)}
-
+
+
+ {templateDirty ? (
+ onDiscard(actionId)}
+ disabled={isSavingTemplate}
+ >
+ {translate(
+ 'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
+ 'Discard'
+ )}
+
+ ) : null}
+ onSave(actionId)}
+ disabled={!templateDirty || isSavingTemplate}
+ >
+ {isSavingTemplate
+ ? translate(
+ 'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
+ 'Saving...'
+ )
+ : translate(
+ 'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
+ 'Save'
+ )}
+
+
diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx
new file mode 100644
index 00000000000..095cfc4b550
--- /dev/null
+++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx
@@ -0,0 +1,106 @@
+// @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 { TooltipProvider } from '../ui/tooltip'
+import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote'
+import type { SourceControlActionRecipeOverrideSummary } from '@/lib/source-control-launch-agent-selection'
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+function renderNote(
+ summary: SourceControlActionRecipeOverrideSummary,
+ onReviewRepo = vi.fn()
+): void {
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+}
+
+describe('SourceControlActionRepoOverrideNote', () => {
+ it('renders nothing when no repo overrides the action', () => {
+ renderNote({ count: 0, overrides: [] })
+ expect(container.textContent).toBe('')
+ })
+
+ it('shows the singular count when one repo overrides the action', () => {
+ renderNote({
+ count: 1,
+ overrides: [{ repoId: 'r1', repoName: 'App', fields: ['commandTemplate'] }]
+ })
+ expect(container.textContent).toContain("Global saves won't change 1 repository")
+ expect(container.textContent).toContain('Review')
+ })
+
+ it('shows the plural count when several repos override the action', () => {
+ renderNote({
+ count: 2,
+ overrides: [
+ { repoId: 'r1', repoName: 'App', fields: ['agent'] },
+ { repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] }
+ ]
+ })
+ expect(container.textContent).toContain("Global saves won't change 2 repositories")
+ expect(container.textContent).toContain('Review first')
+ })
+
+ it('shows a "+N more" note when overrides exceed the visible limit', async () => {
+ renderNote({
+ count: 6,
+ overrides: Array.from({ length: 6 }, (_, index) => ({
+ repoId: `r${index}`,
+ repoName: `Repo ${index}`,
+ fields: ['commandTemplate']
+ }))
+ })
+
+ // The overflow note lives in the tooltip content, only mounted once Radix
+ // opens the tooltip — drive its pointer-move open path and flush the timer.
+ const trigger = container.querySelector('[data-slot="tooltip-trigger"]')
+ await act(async () => {
+ trigger?.dispatchEvent(
+ new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' })
+ )
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+
+ expect(document.body.textContent).toContain('+1 more')
+ })
+
+ it('opens the first repo override from the review action', () => {
+ const onReviewRepo = vi.fn()
+ renderNote(
+ {
+ count: 2,
+ overrides: [
+ { repoId: 'r1', repoName: 'App', fields: ['agent'] },
+ { repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] }
+ ]
+ },
+ onReviewRepo
+ )
+
+ act(() => {
+ container.querySelector('button')?.click()
+ })
+
+ expect(onReviewRepo).toHaveBeenCalledWith('r1')
+ })
+})
diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx
new file mode 100644
index 00000000000..ec389927e42
--- /dev/null
+++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx
@@ -0,0 +1,135 @@
+import type React from 'react'
+import { Info } from 'lucide-react'
+import { Button } from '../ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
+import type {
+ SourceControlActionRecipeOverrideField,
+ SourceControlActionRecipeOverrideSummary
+} from '@/lib/source-control-launch-agent-selection'
+import { translate } from '@/i18n/i18n'
+
+type SourceControlActionRepoOverrideNoteProps = {
+ summary: SourceControlActionRecipeOverrideSummary
+ onReviewRepo: (repoId: string) => void
+}
+
+const MAX_VISIBLE_REPOS = 5
+
+function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideField): string {
+ switch (field) {
+ case 'agent':
+ return translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.agent',
+ 'Agent'
+ )
+ case 'agentArgs':
+ return translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.agentArgs',
+ 'CLI arguments'
+ )
+ case 'commandTemplate':
+ return translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate',
+ 'Command template'
+ )
+ default: {
+ // Fail at compile time if the override-field union grows a new variant.
+ const _exhaustive: never = field
+ return _exhaustive
+ }
+ }
+}
+
+function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string {
+ if (fields.length === 0) {
+ return translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.recipe',
+ 'Recipe'
+ )
+ }
+ return fields.map(getRecipeOverrideFieldLabel).join(', ')
+}
+
+export function SourceControlActionRepoOverrideNote({
+ summary,
+ onReviewRepo
+}: SourceControlActionRepoOverrideNoteProps): React.JSX.Element | null {
+ if (summary.count === 0) {
+ return null
+ }
+ const firstOverride = summary.overrides[0]
+ if (!firstOverride) {
+ return null
+ }
+ const visibleOverrides = summary.overrides.slice(0, MAX_VISIBLE_REPOS)
+ const hiddenOverrideCount = Math.max(0, summary.overrides.length - visibleOverrides.length)
+ const noteText =
+ summary.count === 1
+ ? translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.singular',
+ "Global saves won't change 1 repository with its own recipe."
+ )
+ : translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.plural',
+ "Global saves won't change {{count}} repositories with their own recipes.",
+ { count: summary.count }
+ )
+ return (
+
+
+
+
+
+ {noteText}
+
+
+
+
+
+ {translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.tooltipTitle',
+ 'Repository overrides'
+ )}
+
+
+ {hiddenOverrideCount > 0 ? (
+
+ {translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.more',
+ '+{{count}} more',
+ { count: hiddenOverrideCount }
+ )}
+
+ ) : null}
+
+
+
+
onReviewRepo(firstOverride.repoId)}
+ >
+ {summary.count === 1
+ ? translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.review',
+ 'Review'
+ )
+ : translate(
+ 'auto.components.settings.SourceControlActionRepoOverrideNote.reviewFirst',
+ 'Review first'
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx
index c54390e2a8d..56906be96d3 100644
--- a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx
+++ b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx
@@ -1,6 +1,8 @@
+import { useMemo } from 'react'
import type React from 'react'
import { toast } from 'sonner'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
+import { isFolderRepo } from '../../../../shared/repo-kind'
import type {
SourceControlAiSettings,
SourceControlAiSettingsPatch
@@ -13,9 +15,17 @@ import {
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { Label } from '../ui/label'
+import { useAppStore } from '@/store'
+import { useRepos } from '@/store/selectors'
+import {
+ summarizeReposOverridingActionRecipe,
+ type SourceControlActionRecipeOverrideSummary
+} from '@/lib/source-control-launch-agent-selection'
import { SearchableSetting } from './SearchableSetting'
+import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets'
import { matchesSettingsSearch } from './settings-search'
import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow'
+import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote'
import { useSourceControlActionRecipeDraftState } from './source-control-action-recipe-draft-state'
import { translate } from '@/i18n/i18n'
@@ -84,6 +94,22 @@ export function SourceControlAiActionRecipeDefaults({
searchQuery,
writeConfig
}: SourceControlAiActionRecipeDefaultsProps): React.JSX.Element | null {
+ const allRepos = useRepos()
+ // Recomputes on every keystroke in the sibling recipe textareas otherwise, so
+ // memoize the filter and the O(actions × repos) override scan on `repos`.
+ const repos = useMemo(() => allRepos.filter((repo) => !isFolderRepo(repo)), [allRepos])
+ const overrideSummaries = useMemo(
+ () =>
+ Object.fromEntries(
+ SOURCE_CONTROL_ACTION_IDS.map((actionId) => [
+ actionId,
+ summarizeReposOverridingActionRecipe({ repos, actionId })
+ ])
+ ) as Record
,
+ [repos]
+ )
+ const openSettingsPage = useAppStore((state) => state.openSettingsPage)
+ const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const {
actionRecipeDraftState,
savingActionTemplateIds,
@@ -134,6 +160,18 @@ export function SourceControlAiActionRecipeDefaults({
}
}
+ const openRepoSourceControlAiSettings = (
+ repoId: string,
+ actionId: SourceControlActionId
+ ): void => {
+ openSettingsTarget({
+ pane: 'repo',
+ repoId,
+ sectionId: getRepositorySourceControlAiActionRecipeSectionId(repoId, actionId)
+ })
+ openSettingsPage()
+ }
+
if (!config.enabled || !matchesSettingsSearch(searchQuery, ACTION_RECIPES_SEARCH_ENTRY)) {
return null
}
@@ -158,6 +196,7 @@ export function SourceControlAiActionRecipeDefaults({
{SOURCE_CONTROL_ACTION_IDS.map((actionId) => {
const recipe = config.actions?.[actionId]
const selectedAgent = recipe?.agentId ?? null
+ const overrideSummary = overrideSummaries[actionId]
return (
openRepoSourceControlAiSettings(repoId, actionId)}
+ />
+ }
onAgentChange={(id, value) => void onActionAgentChange(id, value)}
onTemplateChange={onActionTemplateChange}
onAgentArgsChange={onActionAgentArgsChange}
diff --git a/src/renderer/src/components/settings/browser-cookie-import-label.test.ts b/src/renderer/src/components/settings/browser-cookie-import-label.test.ts
new file mode 100644
index 00000000000..108a2e4c8d2
--- /dev/null
+++ b/src/renderer/src/components/settings/browser-cookie-import-label.test.ts
@@ -0,0 +1,53 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import en from '@/i18n/locales/en.json'
+import { i18n, translate } from '@/i18n/i18n'
+
+// Keys for the "From " import-cookies menu entries. The label is built
+// by interpolating the browser name into a single localized string; a regression
+// where the name was concatenated directly onto "From" dropped the space and
+// rendered e.g. "FromGoogle Chrome".
+const IMPORT_FROM_KEYS = [
+ 'auto.components.settings.BrowserProfileRow.c5a273a809',
+ 'auto.components.settings.BrowserUsePane.5301857d88'
+]
+const LAST_IMPORTED_FROM_KEY = 'auto.components.settings.BrowserUsePane.112f70adc4'
+
+function catalogEntry(key: string): unknown {
+ return key.split('.').reduce((node, part) => {
+ if (typeof node === 'object' && node !== null) {
+ return (node as Record)[part]
+ }
+ return undefined
+ }, en)
+}
+
+describe('import-cookies "From " label', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('en')
+ })
+
+ it('keeps a space between "From" and the browser name', () => {
+ for (const key of IMPORT_FROM_KEYS) {
+ expect(translate(key, 'From {{value0}}', { value0: 'Google Chrome' })).toBe(
+ 'From Google Chrome'
+ )
+ expect(translate(key, 'From {{value0}}', { value0: 'Safari' })).toBe('From Safari')
+ }
+ })
+
+ it('stores the catalog entry with an interpolation placeholder', () => {
+ for (const key of IMPORT_FROM_KEYS) {
+ expect(catalogEntry(key)).toBe('From {{value0}}')
+ }
+ expect(catalogEntry(LAST_IMPORTED_FROM_KEY)).toBe('Last imported from {{value0}}')
+ })
+
+ it('keeps a space in the last-imported source label', () => {
+ expect(
+ translate(LAST_IMPORTED_FROM_KEY, 'Last imported from {{value0}}', {
+ value0: 'Google Chrome'
+ })
+ ).toBe('Last imported from Google Chrome')
+ })
+})
diff --git a/src/renderer/src/components/settings/repository-settings-targets.ts b/src/renderer/src/components/settings/repository-settings-targets.ts
index 2ab4fd8d5d5..32bcd1f4510 100644
--- a/src/renderer/src/components/settings/repository-settings-targets.ts
+++ b/src/renderer/src/components/settings/repository-settings-targets.ts
@@ -1,3 +1,5 @@
+import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
+
export function getRepositoryLocalCommandsSectionId(repoId: string): string {
return `repo-${repoId}-local-commands`
}
@@ -9,3 +11,10 @@ export function getRepositoryIconSectionId(repoId: string): string {
export function getRepositorySourceControlAiSectionId(repoId: string): string {
return `repo-${repoId}-source-control-ai`
}
+
+export function getRepositorySourceControlAiActionRecipeSectionId(
+ repoId: string,
+ actionId: SourceControlActionId
+): string {
+ return `repo-${repoId}-source-control-ai-${actionId}`
+}
diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts
new file mode 100644
index 00000000000..1972afde88b
--- /dev/null
+++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest'
+import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
+import { buildActionScopedRepoAiSave } from './repository-source-control-ai-draft'
+
+describe('buildActionScopedRepoAiSave', () => {
+ it('persists only the target action, keeping siblings at their saved values', () => {
+ const persisted: RepoSourceControlAiOverrides = {
+ actionOverrides: {
+ resolveConflicts: {
+ agentId: 'claude',
+ commandInputTemplate: '{basePrompt}',
+ agentArgs: ''
+ },
+ fixCommitFailure: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '' }
+ }
+ }
+ const draft: RepoSourceControlAiOverrides = {
+ actionOverrides: {
+ resolveConflicts: {
+ agentId: 'codex',
+ commandInputTemplate: '{basePrompt}\n\nedited',
+ agentArgs: ''
+ },
+ // A half-finished edit on a different row that must NOT be flushed.
+ fixCommitFailure: {
+ agentId: 'gemini',
+ commandInputTemplate: '{basePrompt}\n\nunsaved',
+ agentArgs: ''
+ }
+ }
+ }
+
+ const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts')
+
+ expect(next.actionOverrides?.resolveConflicts?.agentId).toBe('codex')
+ expect(next.actionOverrides?.resolveConflicts?.commandInputTemplate).toBe(
+ '{basePrompt}\n\nedited'
+ )
+ // Sibling keeps the persisted value, not the draft's pending edit.
+ expect(next.actionOverrides?.fixCommitFailure?.agentId).toBe('codex')
+ expect(next.actionOverrides?.fixCommitFailure?.commandInputTemplate).toBe('{basePrompt}')
+ })
+
+ it('removes the action override when the draft inherits it', () => {
+ const persisted: RepoSourceControlAiOverrides = {
+ actionOverrides: {
+ resolveConflicts: { agentId: 'claude', commandInputTemplate: '{basePrompt}', agentArgs: '' }
+ }
+ }
+ const draft: RepoSourceControlAiOverrides = { actionOverrides: {} }
+
+ const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts')
+
+ expect(next.actionOverrides?.resolveConflicts).toBeUndefined()
+ })
+})
diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts
index 8eb2875a477..a085821f23f 100644
--- a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts
+++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts
@@ -1,8 +1,17 @@
-import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
+import {
+ normalizeRepoSourceControlAiOverrides,
+ resolveSourceControlActionRecipe
+} from '../../../../shared/source-control-ai'
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
+import type { GlobalSettings } from '../../../../shared/types'
+import { completeRepoActionRecipe } from './repository-source-control-ai-labels'
import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options'
+type RepoActionRecipe = NonNullable<
+ NonNullable[SourceControlActionId]
+>
+
export type RepoAiDraftState = {
repoId: string
value: RepoSourceControlAiOverrides
@@ -93,3 +102,66 @@ export function dropRepoLegacyInstructionForAction(
Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined
}
}
+
+export function readCompleteRecipeForDraft(
+ current: RepoSourceControlAiOverrides,
+ settings: GlobalSettings | null,
+ actionId: SourceControlActionId
+): RepoActionRecipe {
+ const recipe = resolveSourceControlActionRecipe({
+ settings,
+ repo: { sourceControlAi: current },
+ actionId
+ })
+ return completeRepoActionRecipe(recipe, actionId)
+}
+
+export function setActionOverride(
+ current: RepoSourceControlAiOverrides,
+ actionId: SourceControlActionId,
+ recipe: RepoActionRecipe
+): RepoSourceControlAiOverrides {
+ return dropRepoLegacyInstructionForAction(
+ {
+ ...current,
+ actionOverrides: {
+ ...current.actionOverrides,
+ [actionId]: recipe
+ }
+ },
+ actionId
+ )
+}
+
+export function serializeActionOverride(
+ value: RepoSourceControlAiOverrides,
+ actionId: SourceControlActionId
+): string {
+ return JSON.stringify({
+ hasOverride: hasOwnActionOverride(value.actionOverrides, actionId),
+ recipe: value.actionOverrides?.[actionId] ?? null
+ })
+}
+
+/**
+ * Layer only one action's draft override onto the last-saved repo settings, so a
+ * per-action save persists that recipe without flushing other rows' edits.
+ */
+export function buildActionScopedRepoAiSave(
+ persisted: RepoSourceControlAiOverrides,
+ draft: RepoSourceControlAiOverrides,
+ actionId: SourceControlActionId
+): RepoSourceControlAiOverrides {
+ const nextActionOverrides = { ...persisted.actionOverrides }
+ if (hasOwnActionOverride(draft.actionOverrides, actionId)) {
+ nextActionOverrides[actionId] = draft.actionOverrides?.[actionId]
+ } else {
+ delete nextActionOverrides[actionId]
+ }
+ return normalizeRepoAiDraft(
+ dropRepoLegacyInstructionForAction(
+ { ...persisted, actionOverrides: nextActionOverrides },
+ actionId
+ )
+ )
+}
diff --git a/src/renderer/src/components/settings/setting-labels.ts b/src/renderer/src/components/settings/setting-labels.ts
index cdc459d9790..0f108fadeae 100644
--- a/src/renderer/src/components/settings/setting-labels.ts
+++ b/src/renderer/src/components/settings/setting-labels.ts
@@ -4,6 +4,7 @@ export const SETTING_LABELS: Partial> = {
terminalFontSize: 'Font Size',
terminalFontFamily: 'Font Family',
terminalFontWeight: 'Font Weight',
+ terminalLineHeight: 'Line Height',
terminalScrollSensitivity: 'Normal Scroll Speed',
terminalFastScrollSensitivity: 'Fast Scroll Speed',
terminalTuiScrollSensitivity: 'TUI Scroll Speed',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx
index 9af104c6c18..4d0fdec107a 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx
@@ -133,7 +133,7 @@ describe('WorktreeCard SSH reconnect prompt', () => {
worktreeCardProperties = ['status']
})
- it('opens the reconnect dialog for an active disconnected SSH worktree during render', () => {
+ it('does not auto-open the blocking reconnect dialog for a restored active disconnected SSH worktree', () => {
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
@@ -141,9 +141,11 @@ describe('WorktreeCard SSH reconnect prompt', () => {
)
- expect(markup).toContain('data-ssh-disconnected-dialog="open"')
- expect(markup).toContain('data-ssh-status="disconnected"')
- expect(markup).toContain('data-ssh-target-label="Remote target"')
+ // The dialog is blocking, so being the active/restored card must not steal
+ // focus app-wide; it only opens on deliberate click (see handleClick).
+ expect(markup).toContain('data-ssh-disconnected-dialog="closed"')
+ // The disconnected state is still discoverable via the non-blocking card chip.
+ expect(markup).toContain('SSH disconnected')
})
it('marks a runtime-host worktree disconnected when its environment has no status', () => {
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index a823442d23d..003878d3acf 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -342,6 +342,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
return state?.status ?? 'disconnected'
})
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
+ // Why: a terminal view already carries its own in-pane reconnect overlay, so
+ // the blocking dialog would just duplicate it there; reserve the dialog for
+ // views without an in-context prompt. Default to terminal (suppress) for the
+ // ambiguous case so we err toward non-blocking.
+ const activeViewIsTerminal = useAppStore(
+ (s) => (s.activeTabTypeByWorktree?.[worktree.id] ?? 'terminal') === 'terminal'
+ )
// Why: runtime ("Orca server") hosts get the same disconnected treatment as
// SSH — when the host's runtime environment has no live status, its worktrees
@@ -353,23 +360,14 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
return !s.runtimeStatusByEnvironmentId.get(parsed.environmentId)?.status
})
+ // Why: the reconnect dialog is blocking, so it is never auto-shown for a
+ // disconnected worktree just because it is the active/restored card — that
+ // would steal focus app-wide while the user works elsewhere. The card chip,
+ // status bar, and terminal overlay carry the non-blocking disconnected state;
+ // the dialog only opens on deliberate focus (see handleClick).
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
- const sshDisconnectedPromptKey = isActive && isSshDisconnected ? worktree.id : null
- const [lastSshDisconnectedPromptKey, setLastSshDisconnectedPromptKey] = useState(
- null
- )
const [titleRenaming, setTitleRenaming] = useState(false)
const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false)
-
- // Why: on restart the previously-active worktree is auto-restored without a
- // click, so the dialog never opens. Auto-show it for the active card when SSH
- // is disconnected, but keep dismissals sticky until that prompt key changes.
- if (sshDisconnectedPromptKey !== lastSshDisconnectedPromptKey) {
- setLastSshDisconnectedPromptKey(sshDisconnectedPromptKey)
- if (sshDisconnectedPromptKey) {
- setShowDisconnectedDialog(true)
- }
- }
// Why: read the target label from the store (populated during hydration in
// useIpcEvents.ts) instead of calling listTargets IPC per card instance.
const sshTargetLabel = useAppStore((s) =>
@@ -862,7 +860,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
})
onImmediateActivate?.(worktree.id, activationRowKey)
void activateWorktreeFromSidebar(worktree.id)
- if (isSshDisconnected) {
+ // Why: clicking the card is a deliberate focus of this project, so the
+ // blocking reconnect prompt is appropriate here (unlike auto-restore) —
+ // but skip it when a terminal is active, since that pane already shows the
+ // in-context reconnect overlay and a second prompt would just duplicate it.
+ if (isSshDisconnected && !activeViewIsTerminal) {
setShowDisconnectedDialog(true)
}
onActivate?.()
@@ -875,6 +877,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
isDeleting,
activationRowKey,
isSshDisconnected,
+ activeViewIsTerminal,
onActivate,
onImmediateActivate,
onSelectionGesture
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx
index 2320889a3e3..dbf77ecf0f9 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -268,7 +268,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options'
import { HostSectionHeaderMenu } from './HostSectionHeaderMenu'
import { ProjectHeaderActions } from './ProjectHeaderActions'
import { translate } from '@/i18n/i18n'
-import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
+import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import {
isConfirmedStaleFolderPathStatus,
@@ -405,6 +405,22 @@ function getWorktreeOptionId(rowKey: string): string {
return `worktree-list-option-${encodeURIComponent(rowKey)}`
}
+// Why: folder workspaces are tracked by the scoped active key, while older
+// worktree-only paths still read activeWorktreeId.
+function getActiveSidebarWorkspaceId(
+ activeWorkspaceKey: string | null,
+ activeWorktreeId: string | null
+): string | null {
+ const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null
+ if (scope?.type === 'folder') {
+ return folderWorkspaceKey(scope.folderWorkspaceId)
+ }
+ if (scope?.type === 'worktree') {
+ return scope.worktreeId
+ }
+ return activeWorktreeId
+}
+
function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] {
const scope = root ?? document
const result: HTMLElement[] = []
@@ -5151,7 +5167,11 @@ const WorktreeList = React.memo(function WorktreeList({
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
- const currentSidebarWorktreeId = activeWorktreeId
+ const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey)
+ const currentSidebarWorktreeId = useMemo(
+ () => getActiveSidebarWorkspaceId(activeWorkspaceKey, activeWorktreeId),
+ [activeWorkspaceKey, activeWorktreeId]
+ )
const groupBy = useAppStore((s) => s.groupBy)
const setGroupBy = useAppStore((s) => s.setGroupBy)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
@@ -6642,31 +6662,31 @@ const WorktreeList = React.memo(function WorktreeList({
})
return
}
- if (!activeWorktreeId) {
+ if (!currentSidebarWorktreeId) {
return
}
const activeWorktree = getKnownSidebarWorktreeById(
- activeWorktreeId,
+ currentSidebarWorktreeId,
worktreeMap,
folderWorkspaces
)
if (!activeWorktree || activeWorktree.isArchived) {
return
}
- if (!renderedWorktreeIds.includes(activeWorktreeId)) {
+ if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) {
// Why: the toolbar action promises to reveal the current workspace; when
// sidebar filters hide it, relax those filters before queuing the reveal.
clearFilters()
}
- revealWorktreeInSidebar(activeWorktreeId, {
+ revealWorktreeInSidebar(currentSidebarWorktreeId, {
behavior: 'smooth',
highlight: true,
beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true
})
},
[
- activeWorktreeId,
clearFilters,
+ currentSidebarWorktreeId,
folderWorkspaces,
revealSidebarRow,
renderedWorktreeIds,
diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
index 217d2fd6735..5163a839ebf 100644
--- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
@@ -11,8 +11,11 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
-import { buildWorktreeMetaUpdates, type WorktreeMetaSavedPayload } from './worktree-meta-updates'
+import {
+ buildWorktreeMetaUpdates,
+ parseGitHubWorkItemNumberForMetaField,
+ type WorktreeMetaSavedPayload
+} from './worktree-meta-updates'
import { useWorktreeIssueLink } from './use-worktree-issue-link'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { ExternalLink, LoaderCircle } from 'lucide-react'
@@ -95,8 +98,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
}
const trimmedIssue = issueInput.trim()
const trimmedPR = prInput.trim()
- const issueValid = trimmedIssue === '' || parseGitHubIssueOrPRNumber(trimmedIssue) !== null
- const prValid = trimmedPR === '' || parseGitHubIssueOrPRNumber(trimmedPR) !== null
+ const issueValid =
+ trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null
+ const prValid =
+ trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null
return issueValid && prValid
}, [worktreeId, issueInput, prInput])
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
new file mode 100644
index 00000000000..a53eeae4163
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+import { buildWorktreeMetaUpdates } from './worktree-meta-updates'
+
+describe('buildWorktreeMetaUpdates', () => {
+ it('rejects issue URLs in the PR input', () => {
+ expect(
+ buildWorktreeMetaUpdates({
+ displayNameInput: 'Workspace',
+ currentDisplayName: 'Workspace',
+ issueInput: '',
+ prInput: 'https://github.com/stablyai/orca/issues/6933',
+ commentInput: ''
+ })
+ ).toEqual({
+ comment: '',
+ linkedIssue: null
+ })
+ })
+
+ it('accepts PR URLs in the PR input', () => {
+ expect(
+ buildWorktreeMetaUpdates({
+ displayNameInput: 'Workspace',
+ currentDisplayName: 'Workspace',
+ issueInput: '',
+ prInput: 'https://github.com/stablyai/orca/pull/6934',
+ commentInput: ''
+ })
+ ).toEqual({
+ comment: '',
+ linkedIssue: null,
+ linkedPR: 6934
+ })
+ })
+
+ it('accepts issue URLs in the issue input', () => {
+ expect(
+ buildWorktreeMetaUpdates({
+ displayNameInput: 'Workspace',
+ currentDisplayName: 'Workspace',
+ issueInput: 'https://github.com/stablyai/orca/issues/6933',
+ prInput: '',
+ commentInput: ''
+ })
+ ).toEqual({
+ comment: '',
+ linkedIssue: 6933,
+ linkedPR: null
+ })
+ })
+
+ it('rejects PR URLs in the issue input', () => {
+ expect(
+ buildWorktreeMetaUpdates({
+ displayNameInput: 'Workspace',
+ currentDisplayName: 'Workspace',
+ issueInput: 'https://github.com/stablyai/orca/pull/6934',
+ prInput: '',
+ commentInput: ''
+ })
+ ).toEqual({
+ comment: '',
+ linkedPR: null
+ })
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
index 7f6f5d70935..26769c65061 100644
--- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
@@ -16,6 +16,20 @@ export function parseExplicitGitHubIssueUrl(input: string): string | null {
return trimmed
}
+export function parseGitHubWorkItemNumberForMetaField(
+ input: string,
+ expectedType: 'issue' | 'pr'
+): number | null {
+ const link = parseGitHubIssueOrPRLink(input)
+ if (link) {
+ // Why: issue and PR numbers live in separate GitHub namespaces for refs;
+ // a URL path mismatch must not silently link the other field.
+ return link.type === expectedType ? link.number : null
+ }
+
+ return parseGitHubIssueOrPRNumber(input)
+}
+
/** Pure save-payload builder for the worktree meta dialog: empty inputs clear
* the link (null), unparseable inputs leave it untouched (omitted). */
export function buildWorktreeMetaUpdates(args: {
@@ -26,11 +40,11 @@ export function buildWorktreeMetaUpdates(args: {
commentInput: string
}): Partial {
const trimmedIssue = args.issueInput.trim()
- const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue)
+ const linkedIssueNumber = parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue')
const finalLinkedIssue =
trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined
const trimmedPR = args.prInput.trim()
- const linkedPRNumber = parseGitHubIssueOrPRNumber(trimmedPR)
+ const linkedPRNumber = parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr')
const finalLinkedPR =
trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined
diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
index ee41d5c6e81..af09c852cab 100644
--- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
+++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 're
import { toast } from 'sonner'
import {
canResumeAiVaultSessionOnTarget,
+ getAiVaultResumeWorkspaceExecutionHostId,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
import {
@@ -169,7 +170,9 @@ export default function AiVaultSessionDropLayer({
return true
}
- const targetStatus = getAiVaultResumeWorkspaceTargetStatus(useAppStore.getState(), worktreeId)
+ const state = useAppStore.getState()
+ const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId)
+ const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId)
if (targetStatus === 'runtime') {
toast.error(
translate(
@@ -191,13 +194,15 @@ export default function AiVaultSessionDropLayer({
if (
!canResumeAiVaultSessionOnTarget({
sessionFilePath: payload.sessionFilePath ?? null,
- targetStatus
+ sessionExecutionHostId: payload.sessionExecutionHostId ?? null,
+ targetStatus,
+ targetExecutionHostId
})
) {
toast.error(
translate(
- 'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported',
- "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead."
+ 'auto.components.tab.group.AiVaultSessionDropLayer.sessionHostMismatchUnsupported',
+ 'This session belongs to a different host. Drop it onto a workspace on the same host.'
)
)
return true
diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx
index a70b378d2bd..0cfa3f1619f 100644
--- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx
@@ -57,8 +57,6 @@ function renderMenu(overrides: Record = {}): void {
canClosePane: true,
canExpandPane: true,
menuPaneIsExpanded: false,
- linkUrl: null,
- onOpenLinkInDefaultBrowser: vi.fn(),
onCopy: vi.fn(),
onPaste: vi.fn(),
onSplitRight: vi.fn(),
@@ -116,24 +114,6 @@ describe('TerminalContextMenu', () => {
expect(onForkAgentSession).not.toHaveBeenCalled()
})
- it('shows "Open in Default Browser" only when a link is under the cursor', () => {
- renderMenu({ linkUrl: null })
- expect(
- items.list.some((item) => childrenText(item.children) === 'Open in Default Browser')
- ).toBe(false)
-
- items.list = []
- const onOpenLinkInDefaultBrowser = vi.fn()
- renderMenu({ linkUrl: 'https://example.com/docs', onOpenLinkInDefaultBrowser })
-
- const openLinkItem = items.list.find(
- (item) => childrenText(item.children) === 'Open in Default Browser'
- )
- expect(openLinkItem).toBeDefined()
- openLinkItem?.onSelect?.()
- expect(onOpenLinkInDefaultBrowser).toHaveBeenCalledTimes(1)
- })
-
it('shows one shortcut per terminal menu action on Windows', () => {
vi.stubGlobal('navigator', {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
index d46d66e5865..749b1e98a28 100644
--- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
@@ -4,7 +4,6 @@ import {
ClipboardCopy,
Copy,
Eraser,
- ExternalLink,
GitFork,
Maximize2,
MessageSquare,
@@ -32,11 +31,12 @@ import {
} from '@/components/ui/dropdown-menu'
import { shouldIgnoreTerminalMenuPointerDownOutside } from './terminal-context-menu-dismiss'
import type { TerminalQuickCommand } from '../../../../shared/types'
+import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
+import { AgentIcon } from '@/lib/agent-catalog'
import type { KeybindingOverrides } from '../../../../shared/keybindings'
import { translate } from '@/i18n/i18n'
import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut'
-import { TerminalQuickCommandMenuItem } from './TerminalQuickCommandMenuItem'
type TerminalContextMenuProps = {
open: boolean
@@ -46,8 +46,6 @@ type TerminalContextMenuProps = {
canClosePane: boolean
canExpandPane: boolean
menuPaneIsExpanded: boolean
- linkUrl: string | null
- onOpenLinkInDefaultBrowser: () => void
onCopy: () => void
onPaste: () => void
onSplitRight: () => void
@@ -83,8 +81,6 @@ export default function TerminalContextMenu({
canClosePane,
canExpandPane,
menuPaneIsExpanded,
- linkUrl,
- onOpenLinkInDefaultBrowser,
onCopy,
onPaste,
onSplitRight,
@@ -132,6 +128,27 @@ export default function TerminalContextMenu({
const showEqualizeShortcut = shortcuts.equalize !== 'Unassigned'
const showSetTitleShortcut = shortcuts.setTitle !== 'Unassigned'
const showClearPaneTitleShortcut = shortcuts.clearPaneTitle !== 'Unassigned'
+ const renderQuickCommandItem = (command: TerminalQuickCommand): React.JSX.Element => (
+ onQuickCommand(command)}>
+ {isTerminalAgentQuickCommand(command) ? (
+
+
+
+ ) : (
+
+ )}
+ {command.label}
+ {!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
+
+ {translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
+
+ ) : null}
+
+ )
return (
- {linkUrl ? (
- <>
-
-
- {translate(
- 'auto.components.terminal.pane.TerminalContextMenu.openLinkDefaultBrowser',
- 'Open in Default Browser'
- )}
-
-
- >
- ) : null}
{translate('auto.components.terminal.pane.TerminalContextMenu.f3eeb1de13', 'Copy')}
@@ -215,13 +220,7 @@ export default function TerminalContextMenu({
{quickCommandRepoLabel}
- {repoQuickCommands.map((command) => (
-
- ))}
+ {repoQuickCommands.map(renderQuickCommandItem)}
>
) : null}
{globalQuickCommands.length > 0 ? (
@@ -235,13 +234,7 @@ export default function TerminalContextMenu({
)}
) : null}
- {globalQuickCommands.map((command) => (
-
- ))}
+ {globalQuickCommands.map(renderQuickCommandItem)}
>
) : null}
>
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index 60771da63fd..afc5f3f70c5 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -93,6 +93,7 @@ import type { AgentType } from '../../../../shared/agent-status-types'
import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
+import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes'
import {
inspectRuntimeTerminalProcess,
isRemoteRuntimePtyId
@@ -350,8 +351,9 @@ export default function TerminalPane({
const daemonActions = useDaemonActions()
// Why: override state lives in a plain Map for perf (safeFit reads it on
// every resize). This counter forces a re-render when overrides change so
- // the mobile-fit banner appears/disappears. When an override is cleared
- // (desktop-fit), we also trigger safeFit on affected panes so the terminal
+ // the mobile-fit banner appears/disappears. On both transitions we also
+ // trigger safeFit on affected panes: mobile-fit shrinks the watcher's xterm
+ // to phone dims (matching the live phone-wrapped stream), and desktop-fit
// resizes back to desktop dimensions.
const [, setOverrideTick] = useState(0)
useEffect(() => {
@@ -376,17 +378,49 @@ export default function TerminalPane({
const unsubscribe = onOverrideChange((event) => {
setOverrideTick((n) => n + 1)
- if (event.mode === 'desktop-fit') {
- const manager = managerRef.current
- if (!manager) {
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+ // Why: pane IDs are per-tab, so resolve the affected PTY through this
+ // tab's live transport bindings instead of global numeric pane IDs.
+ const getAffectedPanes = (): ReturnType =>
+ getOverrideAffectedPanes(
+ manager.getPanes(),
+ (paneId) => paneTransportsRef.current.get(paneId)?.getPtyId(),
+ event.ptyId
+ )
+ if (event.mode === 'mobile-fit') {
+ // Why: when mobile starts driving, the agent re-renders its output at
+ // phone width and that phone-wrapped byte stream flows live into this
+ // passive watcher's xterm. xterm must shrink to the phone dims now or
+ // the wide desktop grid renders the narrow stream as overlapping,
+ // garbled lines. safeFit honors the active override and parks xterm at
+ // override.cols/rows, matching the incoming stream. rAF lets the DOM
+ // settle before the resize; no loud fallback is needed because the
+ // override branch of safeFit is itself the authoritative resize.
+ // Why: override events fan out to every terminal tab; skip the rAF
+ // unless this tab has a pane still parked at the wrong grid.
+ const panesNeedingFit = getPanesNeedingOverrideFit(
+ getAffectedPanes(),
+ event.cols,
+ event.rows
+ )
+ if (panesNeedingFit.length === 0) {
return
}
- // Why: pane IDs are per-tab, so resolve the affected PTY through this
- // tab's live transport bindings instead of global numeric pane IDs.
- const getAffectedPanes = (): ReturnType =>
- manager
- .getPanes()
- .filter((pane) => paneTransportsRef.current.get(pane.id)?.getPtyId() === event.ptyId)
+ scheduleFitFrame(() => {
+ for (const pane of getPanesNeedingOverrideFit(
+ getAffectedPanes(),
+ event.cols,
+ event.rows
+ )) {
+ safeFit(pane)
+ }
+ })
+ return
+ }
+ if (event.mode === 'desktop-fit') {
// Why: fitAddon.fit() measures DOM dimensions, so it must run after
// the browser has settled layout. Running synchronously inside the
// IPC callback can produce stale measurements. rAF ensures the DOM
@@ -1688,6 +1722,7 @@ export default function TerminalPane({
useTerminalKeyboardShortcuts({
tabId,
+ worktreeId,
isActive,
keyboardScopeRef: containerRef,
managerRef,
@@ -2982,8 +3017,6 @@ export default function TerminalPane({
menuPaneIsExpanded={
contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId
}
- linkUrl={contextMenu.menuLinkUrl}
- onOpenLinkInDefaultBrowser={contextMenu.onOpenLinkInDefaultBrowser}
onCopy={() => void contextMenu.onCopy()}
onPaste={() => void contextMenu.onPaste()}
onSplitRight={contextMenu.onSplitRight}
diff --git a/src/renderer/src/components/terminal-pane/TerminalQuickCommandMenuItem.tsx b/src/renderer/src/components/terminal-pane/TerminalQuickCommandMenuItem.tsx
deleted file mode 100644
index 4dac903b0b3..00000000000
--- a/src/renderer/src/components/terminal-pane/TerminalQuickCommandMenuItem.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Play } from 'lucide-react'
-import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
-import { AgentIcon } from '@/lib/agent-catalog'
-import { translate } from '@/i18n/i18n'
-import type { TerminalQuickCommand } from '../../../../shared/types'
-import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
-
-type TerminalQuickCommandMenuItemProps = {
- command: TerminalQuickCommand
- onSelect: (command: TerminalQuickCommand) => void
-}
-
-export function TerminalQuickCommandMenuItem({
- command,
- onSelect
-}: TerminalQuickCommandMenuItemProps): React.JSX.Element {
- return (
- onSelect(command)}>
- {isTerminalAgentQuickCommand(command) ? (
-
-
-
- ) : (
-
- )}
- {command.label}
- {!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
-
- {translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
-
- ) : null}
-
- )
-}
diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
index 35cd9f82fcd..ed78a5d6500 100644
--- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
+++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
@@ -22,6 +22,7 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
+import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty'
import {
markTerminalFollowOutput,
markTerminalPinnedViewport,
@@ -137,6 +138,7 @@ export function matchFileSearchShortcut(
type KeyboardHandlersDeps = {
tabId: string
+ worktreeId: string
isActive: boolean
keyboardScopeRef: React.RefObject
managerRef: React.RefObject
@@ -170,6 +172,7 @@ type KeyboardHandlersDeps = {
*/
export function useTerminalKeyboardShortcuts({
tabId,
+ worktreeId,
isActive,
keyboardScopeRef,
managerRef,
@@ -268,13 +271,36 @@ export function useTerminalKeyboardShortcuts({
return
}
+ // Why: the active pane's live PTY session decides whether Ctrl+Arrow should
+ // pass through as native \e[1;5C/\e[1;5D or be translated to \eb/\ef.
+ // Resolved lazily so session/runtime lookups stay off other keystrokes.
+ const isLocalWindowsConptyPane = (): boolean => {
+ const activePane = manager.getActivePane() ?? manager.getPanes()[0]
+ if (!activePane) {
+ return false
+ }
+ const storeState = useAppStore.getState()
+ return isLocalWindowsConptyPaneForCtrlArrow({
+ isWindows,
+ userAgent: navigator.userAgent,
+ state: storeState,
+ worktreeId,
+ tabId,
+ paneId: activePane.id,
+ paneCwd: paneCwdRef.current,
+ fallbackCwd,
+ transport: paneTransportsRef.current.get(activePane.id) ?? null
+ })
+ }
+
const action = resolveTerminalShortcutAction(
e,
isMac,
macOptionAsAltRef.current,
optionKeyLocation,
isWindows,
- keybindings
+ keybindings,
+ isLocalWindowsConptyPane
)
if (!action) {
return
@@ -513,7 +539,8 @@ export function useTerminalKeyboardShortcuts({
macOptionAsAltRef,
keybindings,
terminalShortcutPolicy,
- tabId
+ tabId,
+ worktreeId
])
}
diff --git a/src/renderer/src/components/terminal-pane/override-affected-panes.test.ts b/src/renderer/src/components/terminal-pane/override-affected-panes.test.ts
new file mode 100644
index 00000000000..ba0ca79d821
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/override-affected-panes.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest'
+import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
+import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes'
+
+function makePane(id: number, cols = 120, rows = 40): ManagedPane {
+ return { id, terminal: { cols, rows } } as ManagedPane
+}
+
+describe('getOverrideAffectedPanes', () => {
+ it('returns only panes bound to the event PTY in this tab', () => {
+ const panes = [makePane(1), makePane(2), makePane(3)]
+ const bindings = new Map([
+ [1, 'pty-a'],
+ [2, 'pty-b'],
+ [3, 'pty-a']
+ ])
+
+ const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-a')
+
+ expect(affected.map((pane) => pane.id)).toEqual([1, 3])
+ })
+
+ it('returns nothing for a watcher whose panes are bound to other PTYs', () => {
+ const panes = [makePane(10), makePane(11)]
+ const bindings = new Map([
+ [10, 'pty-x'],
+ [11, 'pty-y']
+ ])
+
+ const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-z')
+
+ expect(affected).toEqual([])
+ })
+
+ it('ignores unbound panes (resolver returns undefined)', () => {
+ const panes = [makePane(1), makePane(2)]
+ const bindings = new Map([[1, 'pty-a']])
+
+ const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-a')
+
+ expect(affected.map((pane) => pane.id)).toEqual([1])
+ })
+})
+
+describe('getPanesNeedingOverrideFit', () => {
+ it('returns only affected panes whose grid does not already match the override', () => {
+ const panes = [makePane(1, 49, 20), makePane(2, 120, 40), makePane(3, 49, 21)]
+
+ const panesNeedingFit = getPanesNeedingOverrideFit(panes, 49, 20)
+
+ expect(panesNeedingFit.map((pane) => pane.id)).toEqual([2, 3])
+ })
+
+ it('returns nothing when every affected pane already matches the override', () => {
+ const panes = [makePane(1, 49, 20), makePane(2, 49, 20)]
+
+ expect(getPanesNeedingOverrideFit(panes, 49, 20)).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/override-affected-panes.ts b/src/renderer/src/components/terminal-pane/override-affected-panes.ts
new file mode 100644
index 00000000000..98f20bddcab
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/override-affected-panes.ts
@@ -0,0 +1,36 @@
+// Why: a fit-override event names a PTY, but pane IDs are per-tab and the
+// override stream reaches every subscriber (including passive desktop
+// watchers). This resolves which of this tab's panes are bound to the
+// event's PTY via the tab's live transport bindings, so both the
+// mobile-fit and desktop-fit branches of the override listener refit the
+// same set of panes without colliding on global numeric pane IDs.
+
+import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
+
+// Why: transports return string | null and the binding lookup yields
+// undefined for unbound panes, so the resolver tolerates both absent cases.
+export type PanePtyResolver = (paneId: number) => string | null | undefined
+
+export function getOverrideAffectedPanes(
+ panes: readonly ManagedPane[],
+ resolvePtyId: PanePtyResolver,
+ ptyId: string
+): ManagedPane[] {
+ return panes.filter((pane) => resolvePtyId(pane.id) === ptyId)
+}
+
+function paneNeedsOverrideFit(
+ pane: Pick,
+ cols: number,
+ rows: number
+): boolean {
+ return pane.terminal.cols !== cols || pane.terminal.rows !== rows
+}
+
+export function getPanesNeedingOverrideFit(
+ panes: readonly ManagedPane[],
+ cols: number,
+ rows: number
+): ManagedPane[] {
+ return panes.filter((pane) => paneNeedsOverrideFit(pane, cols, rows))
+}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index ca510bfa2e2..3d1a51ba08c 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -3135,6 +3135,45 @@ describe('connectPanePty', () => {
expect(pane.container.dataset.ptyId).toBeUndefined()
})
+ it('refits immediately when binding to a PTY with an active mobile-fit override', async () => {
+ const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides')
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport()
+ transportFactoryQueue.push(transport)
+ const pane = createPane(1)
+ Object.defineProperty(pane.container, 'getBoundingClientRect', {
+ configurable: true,
+ value: () =>
+ ({
+ width: 800,
+ height: 400,
+ top: 0,
+ left: 0,
+ right: 800,
+ bottom: 400
+ }) as DOMRect
+ })
+ ;(
+ pane.fitAddon as unknown as { proposeDimensions: () => { cols: number; rows: number } }
+ ).proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
+ pane.terminal.resize.mockImplementation((cols: number, rows: number) => {
+ pane.terminal.cols = cols
+ pane.terminal.rows = rows
+ })
+ setFitOverride('pty-fit', 'mobile-fit', 49, 20)
+
+ connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
+ const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
+ | ((ptyId: string) => void)
+ | undefined
+ expect(onPtySpawn).toBeTypeOf('function')
+ onPtySpawn?.('pty-fit')
+
+ expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20)
+ expect(pane.terminal.cols).toBe(49)
+ expect(pane.terminal.rows).toBe(20)
+ })
+
it('continues post-spawn size reconcile after a transient mobile presence lock', async () => {
const frameCallbacks: FrameRequestCallback[] = []
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index aaa525320f8..3cad361045e 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -1707,6 +1707,11 @@ export function connectPanePty(
const setPanePtyFitBinding = (ptyId: string): void => {
bindPanePtyId(pane.id, ptyId, deps.tabId)
pane.container.dataset.ptyId = ptyId
+ // Why: override hydration can arrive before this pane knows its PTY. Once
+ // data-pty-id is bound, safeFit can park xterm at the held phone grid.
+ if (getFitOverrideForPty(ptyId)) {
+ safeFit(pane)
+ }
}
let activePanePtyBinding: string | null = null
// Why: bind time lets async liveness reconcile ignore a request started
diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts
new file mode 100644
index 00000000000..8c784bdf3cf
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
+import {
+ isLocalWindowsConptyPaneForCtrlArrow,
+ type TerminalCtrlArrowConptyState
+} from './terminal-ctrl-arrow-conpty'
+import type { PtyTransport } from './pty-transport-types'
+
+const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
+
+function state(
+ overrides: Partial = {}
+): TerminalCtrlArrowConptyState {
+ return {
+ repos: [],
+ worktreesByRepo: {},
+ folderWorkspaces: [],
+ projectGroups: [],
+ settings: null,
+ tabsByWorktree: {},
+ ...overrides
+ }
+}
+
+function localTransport(
+ metadata: { cwd?: string; shellOverride?: string } = {}
+): Pick {
+ return {
+ getPtyId: () => 'pty-1',
+ getConnectionId: () => null,
+ getLocalSessionMetadata: () => metadata
+ }
+}
+
+function remoteRuntimeTransport(): Pick<
+ PtyTransport,
+ 'getPtyId' | 'getConnectionId' | 'getLocalSessionMetadata'
+> {
+ return {
+ getPtyId: () => 'remote:env-1@@terminal-1',
+ getConnectionId: () => null,
+ getLocalSessionMetadata: () => null
+ }
+}
+
+describe('isLocalWindowsConptyPaneForCtrlArrow', () => {
+ it('uses live local session metadata for synthetic floating terminals with no repo record', () => {
+ expect(
+ isLocalWindowsConptyPaneForCtrlArrow({
+ isWindows: true,
+ userAgent: WINDOWS_UA,
+ state: state({
+ tabsByWorktree: {
+ [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: 'tab-1', shellOverride: 'powershell.exe' }]
+ }
+ }),
+ worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
+ tabId: 'tab-1',
+ paneId: 1,
+ paneCwd: new Map(),
+ fallbackCwd: 'C:\\Users\\me',
+ transport: localTransport({ cwd: 'C:\\Users\\me', shellOverride: 'powershell.exe' })
+ })
+ ).toBe(true)
+ })
+
+ it('keeps an already-launched local PowerShell pane local after store ownership changes', () => {
+ const worktreeId = 'repo-1::C:\\repo'
+ expect(
+ isLocalWindowsConptyPaneForCtrlArrow({
+ isWindows: true,
+ userAgent: WINDOWS_UA,
+ state: state({
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ tabsByWorktree: {
+ [worktreeId]: [{ id: 'tab-1', shellOverride: 'wsl.exe' }]
+ }
+ }),
+ worktreeId,
+ tabId: 'tab-1',
+ paneId: 1,
+ paneCwd: new Map([[1, { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me', confirmed: true }]]),
+ fallbackCwd: '\\\\wsl.localhost\\Ubuntu\\home\\me',
+ transport: localTransport({ cwd: 'C:\\repo', shellOverride: 'powershell.exe' })
+ })
+ ).toBe(true)
+ })
+
+ it('does not treat a live remote-runtime terminal as local ConPTY', () => {
+ expect(
+ isLocalWindowsConptyPaneForCtrlArrow({
+ isWindows: true,
+ userAgent: WINDOWS_UA,
+ state: state(),
+ worktreeId: 'repo-1::/remote/repo',
+ tabId: 'tab-1',
+ paneId: 1,
+ paneCwd: new Map([[1, { cwd: '/remote/repo', confirmed: true }]]),
+ fallbackCwd: '/remote/repo',
+ transport: remoteRuntimeTransport()
+ })
+ ).toBe(false)
+ })
+
+ it('stays conservative for unresolved repo-backed panes before a live session exists', () => {
+ expect(
+ isLocalWindowsConptyPaneForCtrlArrow({
+ isWindows: true,
+ userAgent: WINDOWS_UA,
+ state: state(),
+ worktreeId: 'missing-repo::/home/me/repo',
+ tabId: 'tab-1',
+ paneId: 1,
+ paneCwd: new Map([[1, { cwd: '/home/me/repo', confirmed: true }]]),
+ fallbackCwd: '/home/me/repo',
+ transport: null
+ })
+ ).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts
new file mode 100644
index 00000000000..d2bc848886b
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts
@@ -0,0 +1,89 @@
+import type { ExecutionHostId } from '../../../../shared/execution-host'
+import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
+import { getConnectionIdFromState } from '@/lib/connection-context'
+import {
+ getExecutionHostIdForWorktree,
+ type WorktreeRuntimeOwnerState
+} from '@/lib/worktree-runtime-owner'
+import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
+import type { PaneCwdMap } from './resolve-split-cwd'
+import type { PtyTransport } from './pty-transport-types'
+
+const REMOTE_RUNTIME_PTY_ID_PREFIX = 'remote:'
+
+type TerminalTabShellState = {
+ tabsByWorktree: Record<
+ string,
+ readonly { id: string; shellOverride?: string | null }[] | undefined
+ >
+}
+
+export type TerminalCtrlArrowConptyState = Parameters[0] &
+ WorktreeRuntimeOwnerState &
+ TerminalTabShellState
+
+type TerminalCtrlArrowConptyTransport = Pick<
+ PtyTransport,
+ 'getPtyId' | 'getConnectionId' | 'getLocalSessionMetadata'
+>
+
+type TerminalCtrlArrowConptyArgs = {
+ isWindows: boolean
+ userAgent: string
+ state: TerminalCtrlArrowConptyState
+ worktreeId: string
+ tabId: string
+ paneId: number
+ paneCwd: PaneCwdMap
+ fallbackCwd: string
+ transport: TerminalCtrlArrowConptyTransport | null
+}
+
+function isRemoteRuntimePtyId(ptyId: string): boolean {
+ return ptyId.startsWith(REMOTE_RUNTIME_PTY_ID_PREFIX)
+}
+
+export function isLocalWindowsConptyPaneForCtrlArrow({
+ isWindows,
+ userAgent,
+ state,
+ worktreeId,
+ tabId,
+ paneId,
+ paneCwd,
+ fallbackCwd,
+ transport
+}: TerminalCtrlArrowConptyArgs): boolean {
+ if (!isWindows) {
+ return false
+ }
+
+ const ptyId = transport?.getPtyId() ?? null
+ if (ptyId !== null && isRemoteRuntimePtyId(ptyId)) {
+ return false
+ }
+
+ const sessionMetadata = transport?.getLocalSessionMetadata?.()
+ const hasLiveLocalSession =
+ ptyId !== null && sessionMetadata !== null && sessionMetadata !== undefined
+ const transportConnectionId = transport?.getConnectionId?.()
+ const connectionId = hasLiveLocalSession
+ ? null
+ : transportConnectionId === undefined
+ ? getConnectionIdFromState(state, worktreeId)
+ : transportConnectionId
+ const tabShellOverride = state.tabsByWorktree[worktreeId]?.find(
+ (candidate) => candidate.id === tabId
+ )?.shellOverride
+ const executionHostId: ExecutionHostId = hasLiveLocalSession
+ ? LOCAL_EXECUTION_HOST_ID
+ : getExecutionHostIdForWorktree(state, worktreeId)
+
+ return isLocalNativeWindowsConpty({
+ userAgent,
+ connectionId,
+ cwd: sessionMetadata?.cwd ?? paneCwd.get(paneId)?.cwd ?? fallbackCwd,
+ shellOverride: sessionMetadata?.shellOverride ?? tabShellOverride,
+ executionHostId
+ })
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts
index 7e7559db5c0..af6750bee39 100644
--- a/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts
@@ -18,10 +18,9 @@ function event(overrides: Partial): TerminalShortcutEvent
}
describe('non-mac Ctrl+Left/Right word-nav', () => {
- // Windows Terminal, GNOME Terminal, and Konsole all bind Ctrl+←/→ to
- // word-nav. xterm.js emits \e[1;5D / \e[1;5C which default readline doesn't
- // map, so translate to \eb / \ef (same bytes as our Alt+Arrow rule).
- it('translates Ctrl+←/→ on Windows/Linux to readline \\eb / \\ef', () => {
+ // Linux and remote/WSL readline shells don't bind the \e[1;5D / \e[1;5C that
+ // xterm.js emits, so translate to \eb / \ef (same bytes as our Alt+Arrow rule).
+ it('translates Ctrl+←/→ on Linux to readline \\eb / \\ef', () => {
expect(
resolveTerminalShortcutAction(
event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }),
@@ -36,6 +35,64 @@ describe('non-mac Ctrl+Left/Right word-nav', () => {
).toEqual({ type: 'sendInput', data: '\x1bf' })
})
+ // Local Windows ConPTY shells (PowerShell/cmd via PSReadLine) already bind
+ // Ctrl+←/→ to word-nav and self-insert a stray "b"/"f" when fed \eb/\ef
+ // (Escape→RevertLine + self-insert), so the policy must defer to xterm's
+ // native \e[1;5D / \e[1;5C there. Signalled via the isLocalWindowsConptyPane
+ // getter (7th arg).
+ it('does NOT translate Ctrl+←/→ for a local Windows ConPTY pane', () => {
+ expect(
+ resolveTerminalShortcutAction(
+ event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }),
+ false,
+ undefined,
+ undefined,
+ true,
+ undefined,
+ () => true
+ )
+ ).toBeNull()
+ expect(
+ resolveTerminalShortcutAction(
+ event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true }),
+ false,
+ undefined,
+ undefined,
+ true,
+ undefined,
+ () => true
+ )
+ ).toBeNull()
+ })
+
+ // A Windows client SSH'd into Linux (or running WSL) is NOT a local native
+ // Windows ConPTY, so the getter returns false and the readline translation
+ // must still apply — otherwise word-nav silently breaks on the remote shell.
+ it('still translates Ctrl+←/→ on Windows when the pane is not local ConPTY (SSH/WSL)', () => {
+ expect(
+ resolveTerminalShortcutAction(
+ event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }),
+ false,
+ undefined,
+ undefined,
+ true,
+ undefined,
+ () => false
+ )
+ ).toEqual({ type: 'sendInput', data: '\x1bb' })
+ expect(
+ resolveTerminalShortcutAction(
+ event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true }),
+ false,
+ undefined,
+ undefined,
+ true,
+ undefined,
+ () => false
+ )
+ ).toEqual({ type: 'sendInput', data: '\x1bf' })
+ })
+
it('does not translate Ctrl+Arrow on macOS (reserved by OS)', () => {
// Mac uses Cmd+Arrow for line-nav and Option+Arrow for word-nav.
// Ctrl+Arrow is the macOS Mission Control / Spaces chord.
diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts
index d90b6e5ea62..35cc6c00ffe 100644
--- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts
@@ -53,7 +53,12 @@ export function resolveTerminalShortcutAction(
macOptionAsAlt: MacOptionAsAlt = 'false',
optionKeyLocation: number = 0,
isWindows: boolean = false,
- keybindings?: KeybindingOverrides
+ keybindings?: KeybindingOverrides,
+ // Why: lazily reports whether the active pane is a local native Windows
+ // ConPTY (PowerShell/cmd via PSReadLine). Only consulted for the Ctrl+Arrow
+ // word-nav rule below, so the execution-host lookup it performs stays off the
+ // hot path for every other keystroke.
+ isLocalWindowsConptyPane?: () => boolean
): TerminalShortcutAction | null {
const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
if (!event.repeat) {
@@ -206,12 +211,21 @@ export function resolveTerminalShortcutAction(
!event.shiftKey &&
(event.key === 'ArrowLeft' || event.key === 'ArrowRight')
) {
- // Why: Windows Terminal, GNOME Terminal, and Konsole all bind Ctrl+←/→ for
- // word navigation on Linux/Windows — but xterm.js emits \e[1;5D / \e[1;5C,
- // which default readline (bash, zsh) does not bind to backward-word /
- // forward-word. Translate to \eb / \ef (same bytes as our Alt+Arrow rule)
- // so Ctrl+←/→ works for word-nav matching user expectations on those
- // platforms without requiring a custom inputrc.
+ // Why: local Windows ConPTY shells (PowerShell/cmd via PSReadLine) already
+ // bind Ctrl+←/→ to word-nav, and they treat \eb/\ef (Alt+b/f) as
+ // Escape→RevertLine followed by a self-inserted "b"/"f" — so the translation
+ // below prints a stray letter instead of moving the cursor (issue: Ctrl+→
+ // types "b"/"f" in PowerShell). Defer to xterm's native \e[1;5D / \e[1;5C
+ // there. Remote/WSL panes on a Windows client run readline and still need
+ // the translation, so this is gated on a genuine local native ConPTY, not
+ // merely on the client being Windows.
+ if (isLocalWindowsConptyPane?.()) {
+ return null
+ }
+ // Why: default readline (bash, zsh) does not bind the \e[1;5D / \e[1;5C that
+ // xterm.js emits for Ctrl+←/→, so Linux and remote/WSL shells need the
+ // translation to \eb / \ef (same bytes as our Alt+Arrow rule) for word-nav
+ // to work without a custom inputrc.
//
// Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces
// navigation at the OS level and should never reach the app.
diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts
index 8af8a006890..87d03c748b7 100644
--- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts
@@ -1,8 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
extractTerminalHttpLinks,
- getTerminalHttpLinkAtBufferPosition,
- getTerminalHttpLinkForMouseEvent,
TERMINAL_HTTP_URL_MAX_LENGTH
} from './terminal-url-link-hit-testing'
@@ -49,127 +47,3 @@ describe('extractTerminalHttpLinks', () => {
expect(matchAllSpy).not.toHaveBeenCalled()
})
})
-
-describe('terminal HTTP link hit testing', () => {
- type TestBufferLine = {
- isWrapped: boolean
- length: number
- translateToString: (
- trimRight?: boolean,
- startColumn?: number,
- endColumn?: number,
- outColumns?: number[]
- ) => string
- }
-
- function defaultColumnsForText(text: string): number[] {
- return Array.from({ length: text.length + 1 }, (_value, index) => index)
- }
-
- function makeBufferLine(
- text: string,
- options: { isWrapped?: boolean; columns?: number[] } = {}
- ): TestBufferLine {
- const columns = options.columns ?? defaultColumnsForText(text)
- return {
- isWrapped: options.isWrapped ?? false,
- length: text.length,
- translateToString: (
- _trimRight?: boolean,
- startColumn = 0,
- endColumn = text.length,
- outColumns?: number[]
- ) => {
- if (outColumns) {
- outColumns.length = 0
- for (let index = startColumn; index <= endColumn; index++) {
- outColumns.push(columns[index] ?? index)
- }
- }
- return text.slice(startColumn, endColumn)
- }
- }
- }
-
- function makeBuffer(
- rows: TestBufferLine[]
- ): Parameters[0] {
- return { getLine: (y: number) => rows[y] } as Parameters<
- typeof getTerminalHttpLinkAtBufferPosition
- >[0]
- }
-
- it('returns the URL at a buffer position without opening it', () => {
- const line = 'Open https://example.com/docs?q=orca now'
-
- expect(
- getTerminalHttpLinkAtBufferPosition(
- makeBuffer([makeBufferLine(line)]),
- { x: line.indexOf('example') + 1, y: 1 },
- 80
- )
- ).toBe('https://example.com/docs?q=orca')
- })
-
- it('returns null when the buffer position is outside the URL', () => {
- const line = 'Open https://example.com/docs now'
-
- expect(
- getTerminalHttpLinkAtBufferPosition(makeBuffer([makeBufferLine(line)]), { x: 2, y: 1 }, 80)
- ).toBeNull()
- })
-
- // Why: the context-menu "Open in Default Browser" item hands this URL to
- // shell.openExternal, so a dangerous scheme under the cursor must never be
- // surfaced as an openable link.
- it.each(['file:///etc/passwd', 'javascript:alert(1)', 'smb://host/share', 'vscode://x'])(
- 'returns null for the non-http scheme %s',
- (dangerousUrl) => {
- const line = `Open ${dangerousUrl} now`
-
- expect(
- getTerminalHttpLinkAtBufferPosition(
- makeBuffer([makeBufferLine(line)]),
- { x: line.indexOf(dangerousUrl) + 1, y: 1 },
- 80
- )
- ).toBeNull()
- }
- )
-
- it('returns wrapped URLs from mouse coordinates', () => {
- const rows = [
- makeBufferLine('Visit https://exa'),
- makeBufferLine('mple.com/docs', { isWrapped: true })
- ]
- const screen = {
- getBoundingClientRect: () => ({
- left: 10,
- top: 20,
- width: 200,
- height: 40
- })
- }
- const terminal = {
- cols: 20,
- rows: 2,
- element: { querySelector: () => screen },
- buffer: {
- active: {
- viewportY: 0,
- getLine: (y: number) => rows[y]
- }
- }
- }
-
- expect(
- getTerminalHttpLinkForMouseEvent(
- terminal as unknown as Parameters[0],
- {
- clientX: 30,
- clientY: 45
- }
- )
- ).toBe('https://example.com/docs')
- })
-})
diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
index 93ff8954be6..67505d5f622 100644
--- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
@@ -191,7 +191,7 @@ function getTerminalScreenElement(terminal: Terminal): HTMLElement | null {
function getBufferPositionForTerminalMouseEvent(
terminal: Terminal,
- event: Pick
+ event: MouseEvent
): { x: number; y: number } | null {
const screenElement = getTerminalScreenElement(terminal)
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
@@ -260,34 +260,9 @@ export function openHttpLinkAtBufferPosition(
terminalColumns: number,
deps: UrlLinkHitTestDeps
): boolean {
- const url = getTerminalHttpLinkAtBufferPosition(buffer, position, terminalColumns)
- if (!url) {
- return false
- }
-
- openTerminalHttpLink(url, deps)
- return true
-}
-
-export function getTerminalHttpLinkForMouseEvent(
- terminal: Terminal,
- event: Pick