mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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": "/"
|
||||
},
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="Logotipo do OpenClaude" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Logotipo do Pi" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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).
|
||||
@@ -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
@@ -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 }]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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']
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 } : {}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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.`
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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.`
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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"'
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: '' }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user