Merge origin/main (v1.4.123-rc.1) into orca-performance

Conflict: src/renderer/src/lib/github-links.ts (parsing core lives in
src/shared/github-links.ts on this branch). Kept our shared-module
structure; grafted main #7371 semantics into it: exported the named
GitHubIssueOrPRLink type from shared, and the renderer wrapper keeps
normalizeGitHubLinkQuery with the stricter HTTP_URL_PREFIX_RE check and
the new directLink result field.

Also extracted seeded-test-repo.ts from tests/e2e/helpers/orca-app.ts:
the merged fixture crossed the max-lines cap, and the seeded-repo
helpers were a self-contained concept.

Auto-merged hot files verified: main-side changes are spawn-time CWD
canonicalization (#7334/#7403) and a mobile fit-override rebind hook
(#6596) - not on the delivery/restore path, so no bench required per
the branch guide. Throttled wait-blocked machinery intact.

Validated: typecheck clean; daemon + ipc/pty + terminal-pane + both
fuzz suites + github-links run - all failures are pre-existing
Windows-environment ones (identical on a main-based tree), zero
merge-caused.
This commit is contained in:
Jinwoo Hong
2026-07-05 02:34:56 -04:00
231 changed files with 16711 additions and 6228 deletions
+30 -6
View File
@@ -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
+7 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
</p>
<p align="center">
@@ -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)
</td>
<td width="50%">
@@ -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
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
</a>
<p align="center">
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
</p>
## License
Orca is free and open source under the [MIT License](LICENSE).
@@ -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": "/"
},
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.4m">
<title>downloads: 3.4m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.5m">
<title>downloads: 3.5m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.4m</text>
<text x="93.5" y="14">3.4m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.5m</text>
<text x="93.5" y="14">3.5m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
</p>
<p align="center">
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+261
View File
@@ -0,0 +1,261 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrelas no GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Total de downloads em todas as versões" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licença" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Entre no Discord do Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Siga o Orca no X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatíveis: macOS, Windows e Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>O orquestrador de IA para builders 100x.</strong><br/>
Rode Codex, ClaudeCode, OpenCode ou Pi lado a lado — cada um em seu próprio worktree, acompanhado em um só lugar.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Baixar o Orca</ins></a></h3>
<p align="center">
<img src="../assets/readme-hero.jpg" alt="App desktop do Orca executando agentes em worktrees paralelos, com o app companion móvel do Orca no canto" width="960" />
</p>
## Recursos
<table>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop com o app companion móvel" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestração de worktrees paralelos" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminais divididos" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado e Modo Design" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Fluxos de trabalho de tarefas do GitHub e Linear no Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs gerados por IA" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arraste arquivos e imagens para o prompt de um agente" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatize o Orca pela CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**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.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Logotipo do Claude Code" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Logotipo do Codex" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logotipo do Grok" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logotipo do Amp" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="Logotipo do OpenClaude" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Logotipo do Antigravity" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Logotipo do Pi" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="Logotipo do oh-my-pi" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Logotipo do Hermes Agent" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Logotipo do Devin" width="16" valign="middle" /> Devin</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Logotipo do Goose" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Logotipo do Auggie" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Logotipo do Autohand Code" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Logotipo do Charm" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Logotipo do Cline" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Logotipo do Codebuff" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Logotipo do Command Code" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Logotipo do Continue" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Logotipo do Droid" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Logotipo do Kilocode" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Logotipo do Kimi" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Logotipo do Kiro" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Logotipo do Mistral Vibe" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Logotipo do Qwen Code" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Logotipo do Rovo Dev" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ qualquer agente CLI</kbd>
</p>
---
## 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.
<img src="../assets/wechat-qr.png" alt="QR code do WeChat para a comunidade Orca" width="160" />
- **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).
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Contribuidores do Orca" />
</a>
## Licença
Orca é livre e de código aberto sob a [Licença MIT](../../LICENSE).
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+1 -1
View File
@@ -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",
@@ -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<string, Promise<Map<string, string>>>
}): Promise<Map<string, string>> {
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<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
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
}
@@ -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> | 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<AiVaultSession | null> {
return parseMessageGraphSessionContent('pi', file, content, platform, options)
}
function openClawParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
): Promise<AiVaultSession | null> {
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)
}
@@ -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<string, Promise<Map<string, string>>>
}
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<AiVaultSession | null>
}
export type RemoteSessionCandidate = {
source: RemoteSessionSource
file: FileWithMtime
}
@@ -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<string, { content: string; mtimeMs: number }>()
addFile(path: string, content: string, mtimeMs: number): void {
this.files.set(normalize(path), { content, mtimeMs })
}
async readDir(dirPath: string): Promise<DirEntry[]> {
const dir = normalize(dirPath)
const prefix = dir.endsWith('/') ? dir : `${dir}/`
const entries = new Map<string, DirEntry>()
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<FileReadResult> {
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<FileStat> {
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<string> => path
search = unsupported
listFiles = unsupported
watch = unsupported
}
async function unsupported(): Promise<never> {
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 }]
}
}
])
}
+296
View File
@@ -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<AiVaultListResult> {
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<RemoteSessionCandidate[]> {
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<string[]> {
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<string> }> {
const sessions: AiVaultSession[] = []
const parsedFilePaths = new Set<string>()
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<string>
}): Promise<AiVaultSession[]> {
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<AiVaultSession | null> {
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<string, AiVaultSession>()
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<FileWithMtime | null> {
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<T, U>(
items: readonly T[],
mapper: (item: T) => Promise<U>
): Promise<U[]> {
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
}
@@ -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,
@@ -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'
@@ -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<string, Promise<CodexSessionIndexTit
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null
codexHome: string | null = null,
executionHostId?: ExecutionHostId
): Promise<AiVaultSession | null> {
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<string | null>
}): Promise<AiVaultSession | null> {
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<string> | Iterable<string>
platform: NodeJS.Platform
codexHome: string | null
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
titleReader?: (sessionId: string) => Promise<string | null>
}): Promise<AiVaultSession | null> {
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(
@@ -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<AiVaultSession | null> {
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, unknown>): string | null {
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseDroidSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseDroidSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<string, unknown>
): 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
})
}
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseMessageGraphSessionLines({
agent,
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseMessageGraphSessionLines(args: {
agent: 'openclaw' | 'pi'
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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)
}
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseClaudeSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseClaudeSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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(
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseCopilotSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseCopilotSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseCursorSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseCursorSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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)
}
@@ -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 = {
+32 -4
View File
@@ -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<string>
platform: NodeJS.Platform
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
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<AiVaultSession[]> {
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<SessionParseResult> {
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,
+64
View File
@@ -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()
+3 -1
View File
@@ -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()
}
}
+94 -8
View File
@@ -433,6 +433,26 @@ describe('setupGuestShortcutForwarding', () => {
return preventDefault
}
function triggerZoomChanged(direction: 'in' | 'out' | 'reset'): ReturnType<typeof vi.fn> {
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<typeof vi.fn> {
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', () => {
+68 -6
View File
@@ -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<BrowserPageZoomDirection, 'reset'>
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<string>): boolean {
return mouse.modifiers?.some((modifier) => modifiers.has(modifier)) ?? false
@@ -37,7 +70,7 @@ function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet<str
export function resolveGuestMouseWheelZoomDirection(
mouse: Electron.MouseInputEvent,
platform: NodeJS.Platform = process.platform
): BrowserPageZoomDirection | null {
): GuestWheelZoomDirection | null {
if (mouse.type !== 'mouseWheel') {
return null
}
@@ -277,6 +310,15 @@ export function setupGuestShortcutForwarding(args: {
const resetDoubleTapDetector = (): void => 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)
}
@@ -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' })
@@ -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
)
}
+33 -1
View File
@@ -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"]')
})
})
+24 -9
View File
@@ -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
}
}
+105 -9
View File
@@ -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])
)
})
+23 -19
View File
@@ -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
+46 -1
View File
@@ -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 = <name>` 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<string, string | string[]>): Promise<string[]> {
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<GhosttyImportPreview> {
const configPaths = await findGhosttyConfigPaths()
if (configPaths.length === 0) {
@@ -93,7 +132,13 @@ export async function previewGhosttyImport(store: Store): Promise<GhosttyImportP
mergeParsedConfig(parsed, parseGhosttyConfig(content))
}
const { diff: rawDiff, unsupportedKeys } = mapGhosttyToOrca(parsed, platform() === 'darwin')
const themeUnsupportedKeys = await applyThemeReference(parsed)
const { diff: rawDiff, unsupportedKeys: mappedUnsupportedKeys } = mapGhosttyToOrca(
parsed,
platform() === 'darwin'
)
const unsupportedKeys = [...mappedUnsupportedKeys, ...themeUnsupportedKeys]
const currentSettings = store.getSettings()
const actualDiff: Partial<typeof rawDiff> = {}
+104
View File
@@ -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', () => {
+22 -16
View File
@@ -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<number, keyof TerminalColorOverrides> = {
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
+30
View File
@@ -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
}
+171
View File
@@ -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<string, unknown> = {}): 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)'])
})
})
+171
View File
@@ -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)
})
})
+120
View File
@@ -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<string, string | string[]>
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<ThemeReadResult> {
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<ThemeColors | null> {
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
}
+68
View File
@@ -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
+47 -27
View File
@@ -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<number> {
/** 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<boolean> {
try {
await exec(['rev-parse', '--verify', '--quiet', ref])
return true
} catch {
return false
}
}
async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise<string | null> {
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<string | null> {
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(
+71
View File
@@ -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<string, string> {
const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10)
const config: Record<string, string> = {}
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')
})
})
+41 -7
View File
@@ -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']
]
)
}
/**
+8 -5
View File
@@ -10,11 +10,14 @@ export async function hasWorktreeBaseCommitRef(
options: GitExecOptions = {}
): Promise<boolean> {
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
}
+31 -10
View File
@@ -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 }
])
})
+12 -1
View File
@@ -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 } : {}
+8 -11
View File
@@ -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'
}
})
+2
View File
@@ -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
}
+163
View File
@@ -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}`
}
}
+160 -14
View File
@@ -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<AiVaultListResult> {
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<AiVaultListR
}
inflightKey = key
const additionalCodexSessionsDirs =
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => 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<AiVaultListR
return result
})
.finally(() => {
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<AiVaultListResult> {
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<AiVaultListResult> {
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<AiVaultListResult> {
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<string, AiVaultListResult['sessions'][number]>()
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<string[]> {
if (process.platform !== 'win32') {
return []
+41 -14
View File
@@ -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' }
})
+9 -4
View File
@@ -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)
+12
View File
@@ -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'))
@@ -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<unknown>
notify: (method: 'session.registerRoot', payload: { rootPath: string }) => void
}
async function registerSshWorktreeCreateRoots(
mux: SshRootRegistrationMux,
rootPaths: string[]
): Promise<void> {
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<void> {
const mux = getActiveMultiplexer(connectionId)
if (!mux) {
return
}
await registerSshWorktreeCreateRoots(mux, rootPaths)
}
export async function registerRequiredSshWorktreeCreateRoots(
connectionId: string,
rootPaths: string[]
): Promise<void> {
const mux = getActiveMultiplexer(connectionId)
if (!mux) {
throw new Error(SSH_CONNECTION_UNAVAILABLE_MESSAGE)
}
await registerSshWorktreeCreateRoots(mux, rootPaths)
}
+18 -1
View File
@@ -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<void>
// scattered Maps/Sets that previously tracked this state independently.
const activeSessions = new Map<string, SshRelaySession>()
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<void> {
await teardownActiveSshSession(targetId, (session) => session.detach())
}
+207 -74
View File
@@ -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<string, Promise<void>>()
const sshWorktreeCreateFetchCompletedAt = new Map<string, number>()
const sshWorktreeCreateFetchQueueTail = new Map<string, Promise<void>>()
@@ -605,6 +613,35 @@ function hasLocalCommitObjectWithOptions(
)
}
async function hasLocalWorktreeBaseRefWithOptions(
repoPath: string,
baseRef: string,
gitOptions: { wslDistro?: string }
): Promise<boolean> {
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<boolean> {
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<boolean> {
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<string | null> {
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<RemoteWorktreeCreateBasePlan | null> {
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<void> {
// 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.`
)
+601 -8
View File
@@ -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<string>()
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 () => {
@@ -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<ReturnType<typeof readNativeChatTranscriptCached>>): 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)
})
})
+16 -11
View File
@@ -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<number> {
@@ -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()
}
@@ -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' })
})
@@ -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 })
+13 -16
View File
@@ -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<ExitCallback>()
* 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<typeof getShellReadyLaunchConfig> | null = null
if (args.command && isCodexStartupCommand) {
const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({
@@ -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()
})
})
+43
View File
@@ -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.`
)
}
+17
View File
@@ -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
}
@@ -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)
})
})
@@ -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<WindowsProcessRow[]> {
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<WindowsProcessRow[]>({
runPs: runWindowsProcessRows,
now: () => Date.now()
})
export async function queryWindowsProcessDescendants(
rootPid: number
): Promise<WindowsProcessCandidate[] | null> {
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[] {
@@ -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)
})
})
+30
View File
@@ -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
}
+102 -14
View File
@@ -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<ProviderRateLimits> {
async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise<ProviderRateLimits> {
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<ProviderRateLimits> {
}
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<ProviderRateLimits> {
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<ProviderRateLimits> {
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<ProviderRateLimits> {
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<ProviderRateLimits | null> {
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<ProviderRateLimits> {
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<ProviderRateLimits | null> {
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<ProviderRateLimits> {
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) {
+41
View File
@@ -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)
+66 -6
View File
@@ -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<ProviderRateLimits> {
if (options?.signal?.aborted) {
return abortedClaudeUsageResult()
}
const pty = await import('node-pty')
if (options?.signal?.aborted) {
return abortedClaudeUsageResult()
}
return new Promise<ProviderRateLimits>((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<typeof setInterval> | null = null
let timeout: ReturnType<typeof setTimeout> | 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({
+119 -20
View File
@@ -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() })
})
)
+119 -12
View File
@@ -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<CodexBackendAuthHeaders | null> {
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<RateLimitResetCredits | null> {
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<ProviderRateLimits> {
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<ProviderRateLimits> {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
return new Promise<ProviderRateLimits>((resolve) => {
let buffer = ''
let stderr = ''
@@ -396,6 +426,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
: getSpawnArgsForWindows(codexCommand, codexArgs)
const child = spawn(spawnCmd, spawnArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: resolveHiddenRateLimitPtyCwd(),
// Why: the selected Codex rate-limit account must only affect this fetch
// subprocess. Never mutate process.env globally or other Codex features
// would inherit the managed account unintentionally.
@@ -416,6 +447,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
clearTimeout(timeout)
timeout = null
}
options?.signal?.removeEventListener('abort', onAbort)
child.stdout.off('data', onStdoutData)
child.stderr.off('data', onStderrData)
child.off('error', onError)
@@ -434,6 +466,18 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
resolve(result)
}
function onAbort(): void {
settle(abortedCodexRateLimitResult(), { kill: true })
}
if (options?.signal) {
if (options.signal.aborted) {
onAbort()
return
}
options.signal.addEventListener('abort', onAbort, { once: true })
}
timeout = setTimeout(() => {
settle(
{
@@ -632,7 +676,13 @@ function parsePtyStatus(output: string): {
}
async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
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<Provi
let resolved = false
let sentStatus = false
let settleTimer: ReturnType<typeof setTimeout> | null = null
let timeout: ReturnType<typeof setTimeout> | 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<Provi
return
}
resolved = true
clearTimeout(timeout)
if (timeout) {
clearTimeout(timeout)
timeout = null
}
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
// eslint-disable-next-line no-control-regex
@@ -742,7 +825,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
}
if (!resolved) {
resolved = true
clearTimeout(timeout)
if (timeout) {
clearTimeout(timeout)
timeout = null
}
// eslint-disable-next-line no-control-regex
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const { session, weekly } = parsePtyStatus(clean)
@@ -773,6 +859,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
export async function fetchCodexRateLimits(
options?: FetchCodexRateLimitsOptions
): Promise<ProviderRateLimits> {
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 {
@@ -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)
})
})
@@ -7,6 +7,21 @@ type Disposable = {
dispose: () => void
}
const activeHiddenRateLimitPtys = new Set<HiddenPty>()
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[],
@@ -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"'
]
}
+163 -37
View File
@@ -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 () => {
+236 -85
View File
@@ -103,6 +103,7 @@ export class RateLimitService {
private fullFetchQueued = false
private codexOnlyFetchQueued = false
private claudeOnlyFetchQueued = false
private activeFetchAbortControllers = new Set<AbortController>()
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<void>
): Promise<AbortSignal> {
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<void> {
private async runFetchAllCycle(signal: AbortSignal): Promise<void> {
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<void> {
private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise<void> {
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<void> {
private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise<void> {
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 &&
+9 -3
View File
@@ -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> | 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')
+443 -8
View File
@@ -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<string, string> }
| 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<string, string> }
| 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<string, string> }
| 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: '' }
}
+312 -97
View File
@@ -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<string, string>
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<boolean>
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<Record<TuiAgent, string>> | null
disabledTuiAgents?: Iterable<unknown> | 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<boolean> {
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<TerminalCreateOptions> {
// 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<string, string>
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<RuntimeTerminalCreate> {
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<RuntimeMobileSessionCreateTerminalResult> {
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<TerminalWorkspaceLaunchScope, 'path' | 'connectionId'>,
requestedCwd?: string | null
): string | undefined {
return resolveTerminalStartupCwd(workspace.path, requestedCwd, {
canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId)
})
}
private async resolveTerminalWorkspaceLaunchScope(
selector: string
): Promise<TerminalWorkspaceLaunchScope> {
@@ -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
+134
View File
@@ -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()
+124 -17
View File
@@ -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<T>(
open: () => Promise<T>,
signal?: AbortSignal
): Promise<T> {
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<void>((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<T>(
timeoutMessage: string,
register: (callback: (error: Error | undefined, value: T) => void) => void,
@@ -163,23 +217,66 @@ export class SshConnection {
): Promise<T> {
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<NodeJS.EventEmitter> & {
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 })
+21 -2
View File
@@ -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)
+16 -4
View File
@@ -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
+37 -1
View File
@@ -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
}
@@ -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
+12 -4
View File
@@ -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.'
+10 -5
View File
@@ -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 })
+4 -1
View File
@@ -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
@@ -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)
})
})
+1 -33
View File
@@ -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' &&
+49 -6
View File
@@ -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<RemoteTrackingBaseForPrefetch | null>
hasRemoteTrackingRef: (repoPath: string, base: RemoteTrackingBaseForPrefetch) => Promise<boolean>
getOrStartRemoteTrackingBaseRefresh: (
repoPath: string,
base: RemoteTrackingBaseForPrefetch
@@ -24,23 +28,62 @@ type WorktreeCreateBasePrefetchRuntime = {
fetchRemoteWithCache: (repoPath: string, remote: string) => Promise<void>
}
async function hasLocalWorktreeBaseRef(repoPath: string, baseRef: string): Promise<boolean> {
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<void> {
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
}
+53
View File
@@ -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')
})
})
+29
View File
@@ -0,0 +1,29 @@
type ResolveWorktreeCreateBaseArgs = {
requestedBaseBranch?: string
repoWorktreeBaseRef?: string | null
resolveDefaultBaseRef: () => Promise<string | null>
isBaseUsable: (baseBranch: string) => Promise<boolean>
}
export async function resolveWorktreeCreateBase(
args: ResolveWorktreeCreateBaseArgs
): Promise<string | null> {
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
}

Some files were not shown because too many files have changed in this diff Show More