Merge origin/main into brennanb2025/oom-hidden-pane-parking; default parking ON

Resolve agent-detection.ts split-vs-monolith conflict by porting main's
Gemini/Pi title fixes (#5270 masquerade guard, isLegacyPiCompatibleTitle)
into the split module agent-title-core.ts; keep the compatibility barrel.

Flip terminalHiddenViewParking default OFF->ON (byte mode). The three
authority/gate/query flags stay false (no main emitter in this build).
Update the byte-mode guard test, docs, reliability gate, and e2e comments.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-05 14:51:51 -07:00
co-authored by Orca
258 changed files with 16299 additions and 2102 deletions
+30 -6
View File
@@ -883,12 +883,32 @@ jobs:
if: matrix.platform == 'win'
shell: pwsh
run: |
# Why: Some hosted Windows images start without PSGallery registered.
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -InstallationPolicy Trusted
}
$ErrorActionPreference = 'Stop'
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
# Why: on some hosted Windows images `Register-PSRepository -Default`
# fails inside the legacy nuget.exe provider with "Missing option value
# for: '-source'", so PSGallery is never registered and the install
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
# avoids that code path, so prefer it and fall back to PowerShellGet only
# when it is absent.
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
} else {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -InstallationPolicy Trusted
}
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
}
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
@@ -917,7 +937,11 @@ jobs:
}
try {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
if ($useResourceGet) {
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
} else {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
}
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
break
+7 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
</p>
<p align="center">
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
@@ -256,6 +256,10 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
</a>
<p align="center">
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
</p>
## License
Orca is free and open source under the [MIT License](LICENSE).
+24
View File
@@ -384,3 +384,27 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
}
#endif
diff --git a/lib/utils.js b/lib/utils.js
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -19,7 +19,20 @@ function loadNativeModule(name) {
var dirs = ['build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch];
// Check relative to the parent dir for unbundled and then the current dir for bundled
var relative = ['..', '.'];
var lastError;
+ // Why (Orca): the Windows updater force-closes every process whose image
+ // lives under the app install dir; this env var relocates the native
+ // binaries (conpty.dll spawns OpenConsole.exe from beside itself) so PTY
+ // console hosts run outside it and survive updates.
+ var overrideDir = process.env.ORCA_NODE_PTY_NATIVE_DIR;
+ if (overrideDir) {
+ try {
+ return { dir: overrideDir, module: require(overrideDir + "/" + name + ".node") };
+ }
+ catch (e) {
+ lastError = e;
+ }
+ }
for (var _i = 0, dirs_1 = dirs; _i < dirs_1.length; _i++) {
var d = dirs_1[_i];
for (var _a = 0, relative_1 = relative; _a < relative_1.length; _a++) {
+2 -2
View File
@@ -2226,11 +2226,11 @@
"promotionCriteria": [
"Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
"Confirm the memory relief and notification correctness on a real Windows multi-agent workspace with parking opted in.",
"Land the three parking e2e specs in the scheduled E2E suite with green history before flipping terminalHiddenViewParking on by default."
"Land the three parking e2e specs in the scheduled E2E suite with green history to keep terminalHiddenViewParking on by default."
],
"knownGaps": [
"The three Electron e2e specs are scheduled-suite only, not PR-blocking CI.",
"Windows and Linux runs pending; parking itself ships default-off until the soak completes.",
"Linux run pending; parking ships default-on (byte mode) with a kill switch.",
"Fact mode (main-process side-effect authority) is intentionally absent until PR #7214 lands its emitter; the guard pins byte mode until then."
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.5m">
<title>downloads: 3.5m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.6m">
<title>downloads: 3.6m</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.5m</text>
<text x="93.5" y="14">3.5m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.6m</text>
<text x="93.5" y="14">3.6m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
</p>
<p align="center">
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
+261
View File
@@ -0,0 +1,261 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrelas no GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Total de downloads em todas as versões" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licença" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Entre no Discord do Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Siga o Orca no X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatíveis: macOS, Windows e Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>O orquestrador de IA para builders 100x.</strong><br/>
Rode Codex, ClaudeCode, OpenCode ou Pi lado a lado — cada um em seu próprio worktree, acompanhado em um só lugar.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Baixar o Orca</ins></a></h3>
<p align="center">
<img src="../assets/readme-hero.jpg" alt="App desktop do Orca executando agentes em worktrees paralelos, com o app companion móvel do Orca no canto" width="960" />
</p>
## Recursos
<table>
<tr>
<td width="50%" valign="middle">
### Companion móvel
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop com o app companion móvel" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Worktrees paralelos
Envie um mesmo prompt para cinco agentes, cada um em seu próprio worktree git isolado — compare os resultados e faça merge do vencedor.
[Docs →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestração de worktrees paralelos" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Terminais divididos
Terminais no nível do Ghostty com renderização WebGL, divisões infinitas e scrollback que sobrevive a reinicializações.
[Docs →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminais divididos" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Modo Design
Clique em qualquer elemento de UI em uma janela real do Chromium para enviar HTML, CSS e uma captura recortada direto para o prompt do seu agente.
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado e Modo Design" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub e Linear nativos
Navegue por PRs, issues e quadros de projeto dentro do app — abra um worktree a partir de qualquer tarefa e revise sem trocar de contexto.
[Docs →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Fluxos de trabalho de tarefas do GitHub e Linear no Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Worktrees por SSH
Execute agentes em uma máquina remota potente com edição completa de arquivos, git e terminais — com reconexão automática e encaminhamento de portas incluídos.
[Docs →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Anotar diffs de IA
Deixe comentários em qualquer linha de diff e envie-os de volta ao agente — revise, edite e faça commit sem sair do Orca.
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs gerados por IA" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Arraste arquivos para agentes
O editor do VS Code com salvamento automático em todos os lugares — arraste arquivos ou imagens direto para o prompt de um agente.
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arraste arquivos e imagens para o prompt de um agente" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
Agentes também controlam o Orca — automatize qualquer fluxo de trabalho com `orca worktree create`, `snapshot`, `click` e `fill`.
[Docs →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatize o Orca pela CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**Também incluído:**
- **[Abertura rápida](https://www.onorca.dev/docs/model/quick-open)** — Pesquise entre worktrees, arquivos, agentes, comandos e contexto do repositório sem sair do seu fluxo.
- **[Troca de contas e acompanhamento de uso](https://www.onorca.dev/docs/agents/usage-tracking)** — Veja o uso de Claude e Codex, os reinícios de limites e troque contas instantaneamente sem fazer login de novo.
- **[Prévias ricas do repositório](https://www.onorca.dev/docs/editing/markdown)** — Pré-visualize Markdown, imagens, PDFs e documentos do repositório no workspace.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Deixe agentes operarem apps de desktop e UI visível quando um fluxo de trabalho precisa de interação real.
- **[Notificações e estado de não lido](https://www.onorca.dev/docs/notifications)** — Saiba quando um agente termina ou precisa de atenção, depois marque conversas como não lidas para voltar depois.
- **E muito, muito mais** — lançamos novidades todos os dias, então esta lista vive atrasada. O [changelog](https://github.com/stablyai/orca/releases) é a lista real de recursos.
---
## Agentes compatíveis
Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Logotipo do Claude Code" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Logotipo do Codex" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logotipo do Grok" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logotipo do Amp" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="Logotipo do OpenClaude" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Logotipo do Antigravity" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Logotipo do Pi" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="Logotipo do oh-my-pi" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Logotipo do Hermes Agent" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Logotipo do Devin" width="16" valign="middle" /> Devin</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Logotipo do Goose" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Logotipo do Auggie" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Logotipo do Autohand Code" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Logotipo do Charm" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Logotipo do Cline" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Logotipo do Codebuff" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Logotipo do Command Code" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Logotipo do Continue" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Logotipo do Droid" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Logotipo do Kilocode" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Logotipo do Kimi" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Logotipo do Kiro" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Logotipo do Mistral Vibe" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Logotipo do Qwen Code" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Logotipo do Rovo Dev" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ qualquer agente CLI</kbd>
</p>
---
## Instalação
### Desktop — macOS, Windows, Linux
- **[Baixe em onOrca.dev](https://onorca.dev/download)**
- Ou baixe um build diretamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos os builds](https://github.com/stablyai/orca/releases/latest)
- Rodando `orca serve` em um servidor Linux headless? Veja o [guia de servidor Linux headless](../reference/headless-linux-server.md).
_Ou por um gerenciador de pacotes:_
```bash
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
# Arch Linux (AUR) — ou stably-orca-git para compilar a partir do código-fonte
yay -S stably-orca-bin
```
### Companion móvel — iOS, Android
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
## Comunidade e suporte
- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
- **WeChat:** Escaneie o QR code para entrar na comunidade.
<img src="../assets/wechat-qr.png" alt="QR code do WeChat para a comunidade Orca" width="160" />
- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
---
## Desenvolvimento
Quer contribuir ou rodar localmente? Veja nosso guia [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Contribuidores do Orca" />
</a>
## Licença
Orca é livre e de código aberto sob a [Licença MIT](../../LICENSE).
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
---
@@ -1,8 +1,8 @@
# Terminal Hidden View Parking
Status: Shipped standalone (byte mode only, extracted from PR #7214), kill
switch `terminalHiddenViewParking` **default off** — opt-in until the memory
win is confirmed in a soak, then a follow-up flips it on. The companion doc
switch `terminalHiddenViewParking` **default on** — set it to `false` to
disable parking entirely. The companion doc
`terminal-model-view-contract.md` (invariants this design extends and the full
phase list) ships with PR #7214 together with the fact-mode main emitter; in
this build `terminalMainSideEffectAuthority` must stay `false` (see
@@ -15,6 +15,10 @@ function isConflicting(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
}
function isUnstable(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.mergeStateStatus === 'UNSTABLE'
}
function hasReviewRequirement(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED'
}
@@ -27,7 +31,9 @@ function canMergeImmediately(item: MobilePRAutoMergeAvailabilityInput): boolean
}
function canRequestWhenReady(item: MobilePRAutoMergeAvailabilityInput): boolean {
if (item.state !== 'open' || isConflicting(item)) {
// Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected
// by the mutation rather than becoming a waitable auto-merge request.
if (item.state !== 'open' || isConflicting(item) || isUnstable(item)) {
return false
}
if (item.mergeQueueRequired === true) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.122-rc.4",
"version": "1.4.124-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+3 -3
View File
@@ -12,7 +12,7 @@ patchedDependencies:
hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
node-pty@1.1.0:
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
hash: b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -54,7 +54,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282)
version: 1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -11747,7 +11747,7 @@ snapshots:
undici: 6.27.0
which: 6.0.1
node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282):
node-pty@1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88):
dependencies:
node-addon-api: 7.1.1
+9
View File
@@ -0,0 +1,9 @@
<svg width="36" height="32" viewBox="0 0 36.9143 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="paint0_linear" x1="0" y1="15.5" x2="36.9143" y2="15.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#E21680"/>
<stop offset="1" stop-color="#FF633A"/>
</linearGradient>
</defs>
<path d="M17.5802 3.08155C17.5802 2.35036 16.987 1.75702 16.2589 1.75702C15.5309 1.75702 14.9377 2.35171 14.9377 3.08155V25.0348C14.9377 26.7337 13.5586 28.1164 11.8639 28.1164C10.1693 28.1164 8.79008 26.7337 8.79008 25.0348V10.9503C8.79008 10.2191 8.19688 9.62575 7.46886 9.62575C6.74085 9.62575 6.14765 10.2204 6.14765 10.9503V16.9201C6.14765 18.619 4.76846 20.0016 3.07381 20.0016C1.37915 20.0016 0 18.619 0 16.9201V14.7563C0 14.2711 0.392318 13.8778 0.876312 13.8778C1.36031 13.8778 1.75262 14.2711 1.75262 14.7563V16.9201C1.75262 17.6513 2.34579 18.2446 3.07381 18.2446C3.80182 18.2446 4.39502 17.6499 4.39502 16.9201V10.9503C4.39502 9.25138 5.77421 7.86874 7.46886 7.86874C9.16352 7.86874 10.5427 9.25138 10.5427 10.9503V25.0348C10.5427 25.766 11.1359 26.3593 11.8639 26.3593C12.5919 26.3593 13.1851 25.7646 13.1851 25.0348V16.4227V3.08155C13.1851 1.38265 14.5643 0 16.2589 0C17.9536 0 19.3328 1.38265 19.3328 3.08155V21.2599C19.3328 21.7451 18.9405 22.1384 18.4565 22.1384C17.9725 22.1384 17.5802 21.7451 17.5802 21.2599V3.08155ZM33.8405 7.86874C32.1458 7.86874 30.7666 9.25138 30.7666 10.9503V22.7155C30.7666 23.4467 30.1734 24.0401 29.4454 24.0401C28.7174 24.0401 28.1242 23.4454 28.1242 22.7155V3.08155C28.1242 1.38265 26.745 0 25.0504 0C23.3557 0 21.9766 1.38265 21.9766 3.08155V28.0042C21.9766 28.7354 21.3834 29.3287 20.6553 29.3287C19.9273 29.3287 19.3341 28.734 19.3341 28.0042V24.9186C19.3341 24.4334 18.9418 24.0401 18.4578 24.0401C17.9738 24.0401 17.5815 24.4334 17.5815 24.9186V28.0042C17.5815 29.7031 18.9607 31.0857 20.6553 31.0857C22.35 31.0857 23.7292 29.7031 23.7292 28.0042V3.08155C23.7292 2.35036 24.3224 1.75702 25.0504 1.75702C25.7784 1.75702 26.3716 2.35171 26.3716 3.08155V22.7155C26.3716 24.4144 27.7508 25.7971 29.4454 25.7971C31.1401 25.7971 32.5193 24.4144 32.5193 22.7155V10.9503C32.5193 10.2191 33.1125 9.62575 33.8405 9.62575C34.5685 9.62575 35.1617 10.2204 35.1617 10.9503V21.2599C35.1617 21.7451 35.554 22.1384 36.038 22.1384C36.522 22.1384 36.9143 21.7451 36.9143 21.2599V10.9503C36.9143 9.25138 35.5351 7.86874 33.8405 7.86874Z" fill="url(#paint0_linear)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,51 @@
import type { IFilesystemProvider } from '../providers/types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
export async function remoteCodexIndexTitles(args: {
provider: IFilesystemProvider
codexHome: string
hostPlatform: RemoteHostPlatform
titleCaches: Map<string, Promise<Map<string, string>>>
}): Promise<Map<string, string>> {
const cached = args.titleCaches.get(args.codexHome)
if (cached) {
return cached
}
const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform)
args.titleCaches.set(args.codexHome, pending)
return pending
}
async function readRemoteCodexIndexTitles(
provider: IFilesystemProvider,
codexHome: string,
hostPlatform: RemoteHostPlatform
): Promise<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
try {
const { content, isBinary } = await provider.readFile(
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
)
if (isBinary) {
return titleBySessionId
}
for (const line of content.split(/\r?\n/)) {
const record = parseJsonObject(line)
if (!record) {
continue
}
const sessionId = extractString(record.id)
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
if (sessionId && title) {
titleBySessionId.set(sessionId, title)
}
}
} catch {
// Codex indexes are opportunistic; raw transcripts remain sufficient.
}
return titleBySessionId
}
@@ -0,0 +1,221 @@
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { parseCodexSessionContent } from './session-scanner-codex-parser'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
import { parseDroidSessionContent } from './session-scanner-droid-parser'
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
import {
parseClaudeSessionContent,
parseGeminiSessionContent
} from './session-scanner-primary-parsers'
import {
parseCopilotSessionContent,
parseCursorSessionContent,
parseHermesSessionContent
} from './session-scanner-secondary-parsers'
import type { FileWithMtime } from './session-scanner-types'
import { normalizePiSessionsDir } from './session-scanner-values'
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
import type {
RemoteParserOptions,
RemoteScannerContext,
RemoteSessionSource
} from './remote-session-scanner-types'
type RemoteContentParser = (
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
) => Promise<AiVaultSession | null> | AiVaultSession | null
export function remoteSessionSources(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RemoteSessionSource[] {
return [
...remoteCodexSources(remoteHome, hostPlatform),
jsonlSource(
'claude',
remoteHome,
hostPlatform,
['.claude', 'projects'],
parseClaudeSessionContent
),
source(
'gemini',
remoteHome,
hostPlatform,
['.gemini', 'tmp'],
['.json', '.jsonl'],
parseGeminiSessionContent
),
jsonlSource(
'copilot',
remoteHome,
hostPlatform,
['.copilot', 'session-state'],
parseCopilotSessionContent
),
jsonlSource(
'cursor',
remoteHome,
hostPlatform,
['.cursor', 'projects'],
parseCursorSessionContent,
(path) => remotePathSegments(path).includes('agent-transcripts')
),
source(
'hermes',
remoteHome,
hostPlatform,
['.hermes', 'sessions'],
['.json'],
parseHermesSessionContent
),
source(
'devin',
remoteHome,
hostPlatform,
['.local', 'share', 'devin', 'cli', 'transcripts'],
['.json'],
parseDevinSessionContent
),
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
jsonlSource(
'droid',
remoteHome,
hostPlatform,
['.factory', 'sessions'],
parseDroidSessionContent
),
jsonlSource(
'droid',
remoteHome,
hostPlatform,
['.factory', 'projects'],
parseDroidSessionContent
),
...remoteOpenClawSources(remoteHome, hostPlatform)
]
}
function source(
agent: AiVaultAgent,
remoteHome: string,
hostPlatform: RemoteHostPlatform,
segments: readonly string[],
extensions: readonly string[],
parseContent: RemoteContentParser,
filePredicate?: (path: string) => boolean
): RemoteSessionSource {
return {
agent,
rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments),
extensions,
filePredicate,
parse: (file, content, context) =>
Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
}
}
function jsonlSource(
agent: AiVaultAgent,
remoteHome: string,
hostPlatform: RemoteHostPlatform,
segments: readonly string[],
parseContent: RemoteContentParser,
filePredicate?: (path: string) => boolean
): RemoteSessionSource {
return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate)
}
function remoteCodexSources(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RemoteSessionSource[] {
return [
joinRemotePath(hostPlatform, remoteHome, '.codex'),
joinRemotePath(
hostPlatform,
remoteHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
].map((codexHome) => ({
agent: 'codex',
rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'),
extensions: ['.jsonl'],
parse: (file, content, context) =>
parseCodexSessionContent({
file,
content,
platform: context.hostPlatform.os,
codexHome,
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os,
readIndexedTitle: async (sessionId) =>
(
await remoteCodexIndexTitles({
provider: context.provider,
codexHome,
hostPlatform,
titleCaches: context.titleCaches
})
).get(sessionId) ?? null
})
}))
}
function remoteOpenClawSources(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RemoteSessionSource[] {
return ['.openclaw', '.clawdbot'].map((rootName) =>
jsonlSource(
'openclaw',
remoteHome,
hostPlatform,
[rootName, 'agents'],
openClawParser,
(path) => remotePathSegments(path).includes('sessions')
)
)
}
function parserOptions(context: RemoteScannerContext): RemoteParserOptions {
return {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
}
}
function piParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('pi', file, content, platform, options)
}
function openClawParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('openclaw', file, content, platform, options)
}
function remotePathSegments(path: string): string[] {
return path.replace(/\\/g, '/').split('/').filter(Boolean)
}
function remotePiSessionsSegments(): string[] {
return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
}
@@ -0,0 +1,34 @@
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { IFilesystemProvider } from '../providers/types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import type { FileWithMtime } from './session-scanner-types'
export type RemoteScannerContext = {
provider: IFilesystemProvider
executionHostId: ExecutionHostId
hostPlatform: RemoteHostPlatform
titleCaches: Map<string, Promise<Map<string, string>>>
}
export type RemoteParserOptions = {
executionHostId: ExecutionHostId
executionHostPlatform: NodeJS.Platform
}
export type RemoteSessionSource = {
agent: AiVaultAgent
rootDir: string
extensions: readonly string[]
filePredicate?: (path: string) => boolean
parse: (
file: FileWithMtime,
content: string,
context: RemoteScannerContext
) => Promise<AiVaultSession | null>
}
export type RemoteSessionCandidate = {
source: RemoteSessionSource
file: FileWithMtime
}
@@ -0,0 +1,344 @@
import { describe, expect, it } from 'vitest'
import type { DirEntry } from '../../shared/types'
import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
class MemoryRemoteProvider implements IFilesystemProvider {
private readonly files = new Map<string, { content: string; mtimeMs: number }>()
addFile(path: string, content: string, mtimeMs: number): void {
this.files.set(normalize(path), { content, mtimeMs })
}
async readDir(dirPath: string): Promise<DirEntry[]> {
const dir = normalize(dirPath)
const prefix = dir.endsWith('/') ? dir : `${dir}/`
const entries = new Map<string, DirEntry>()
for (const path of this.files.keys()) {
if (!path.startsWith(prefix)) {
continue
}
const relative = path.slice(prefix.length)
if (!relative) {
continue
}
const [name, ...rest] = relative.split('/')
if (!name) {
continue
}
entries.set(name, {
name,
isDirectory: rest.length > 0,
isSymlink: false
})
}
return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name))
}
async readFile(filePath: string): Promise<FileReadResult> {
const file = this.files.get(normalize(filePath))
if (!file) {
throw new Error(`ENOENT: ${filePath}`)
}
return { content: file.content, isBinary: false }
}
async stat(filePath: string): Promise<FileStat> {
const file = this.files.get(normalize(filePath))
if (!file) {
throw new Error(`ENOENT: ${filePath}`)
}
return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs }
}
writeFile = unsupported
writeFileBase64 = unsupported
writeFileBase64Chunk = unsupported
deletePath = unsupported
createFile = unsupported
createDir = unsupported
createDirNoClobber = unsupported
rename = unsupported
renameNoClobber = unsupported
copy = unsupported
realpath = async (path: string): Promise<string> => path
search = unsupported
listFiles = unsupported
watch = unsupported
}
async function unsupported(): Promise<never> {
throw new Error('unsupported')
}
function normalize(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+$/, '')
}
function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
describe('scanRemoteAiVaultSessions', () => {
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.codex/session_index.jsonl',
jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]),
1
)
provider.addFile(
'/home/ada/.codex/sessions/2026/07/04/default.jsonl',
jsonLines([
{
timestamp: '2026-07-04T01:00:00.000Z',
type: 'session_meta',
payload: { id: 'default-session', cwd: '/home/ada/repo' }
},
{
timestamp: '2026-07-04T01:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Fallback default title' }]
}
}
]),
10
)
provider.addFile(
'/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl',
jsonLines([
{
timestamp: '2026-07-04T02:00:00.000Z',
type: 'session_meta',
payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' }
},
{
timestamp: '2026-07-04T02:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Managed remote title' }]
}
}
]),
20
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.title)).toEqual([
'Managed remote title',
'Indexed remote title'
])
expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2)
expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true)
expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true)
expect(
result.sessions.find((session) => session.sessionId === 'default-session')
).toMatchObject({
codexHome: '/home/ada/.codex',
resumeCommand:
"cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'"
})
expect(
result.sessions.find((session) => session.sessionId === 'runtime-session')
).toMatchObject({
codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home',
resumeCommand:
"cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'"
})
})
it('parses non-Codex transcripts through the same remote scanner', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/claude-session.jsonl',
jsonLines([
{
sessionId: 'claude-session',
timestamp: '2026-07-04T04:00:00.000Z',
type: 'user',
message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] }
},
{
sessionId: 'claude-session',
timestamp: '2026-07-04T04:00:01.000Z',
type: 'assistant',
message: { model: 'claude-opus-4', content: 'Sure.' }
}
]),
40
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
executionHostId: 'ssh:dev-box',
executionHostPlatform: 'linux',
agent: 'claude',
sessionId: 'claude-session',
title: 'Summarize the remote branch',
model: 'claude-opus-4',
filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl'
})
})
it('builds resume commands with the remote host platform', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'C:/Users/Ada/.codex/sessions/win.jsonl',
jsonLines([
{
timestamp: '2026-07-04T03:00:00.000Z',
type: 'session_meta',
payload: { id: 'win-session', cwd: 'C:/repo/app' }
},
{
timestamp: '2026-07-04T03:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Windows remote title' }]
}
}
]),
30
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:win-box',
remoteHome: 'C:/Users/Ada',
hostPlatform: getRemoteHostPlatform('win32-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions[0]?.executionHostPlatform).toBe('win32')
expect(result.sessions[0]?.resumeCommand).toBe(
'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""'
)
})
it('continues past skipped candidates to fill the remote scan limit', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.codex/sessions/worker.jsonl',
codexTranscript({
sessionId: 'worker-session',
title: 'Internal worker',
cwd: '/home/ada/repo',
timestamp: '2026-07-04T04:00:00.000Z',
threadSource: 'agent'
}),
40
)
provider.addFile(
'/home/ada/.codex/sessions/user.jsonl',
codexTranscript({
sessionId: 'user-session',
title: 'Visible user session',
cwd: '/home/ada/repo',
timestamp: '2026-07-04T03:00:00.000Z'
}),
30
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session'])
})
it('keeps scoped remote sessions even when they are older than the recency cap', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.codex/sessions/other.jsonl',
codexTranscript({
sessionId: 'other-session',
title: 'Other workspace',
cwd: '/home/ada/other',
timestamp: '2026-07-04T05:00:00.000Z'
}),
50
)
provider.addFile(
'/home/ada/.codex/sessions/scoped.jsonl',
codexTranscript({
sessionId: 'scoped-session',
title: 'Scoped workspace',
cwd: '/home/ada/repo/app',
timestamp: '2026-07-04T01:00:00.000Z'
}),
10
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1,
scopePaths: ['/home/ada/repo']
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'other-session',
'scoped-session'
])
})
})
function codexTranscript(args: {
sessionId: string
title: string
cwd: string
timestamp: string
threadSource?: string
}): string {
return jsonLines([
{
timestamp: args.timestamp,
type: 'session_meta',
payload: {
id: args.sessionId,
cwd: args.cwd,
...(args.threadSource ? { thread_source: args.threadSource } : {})
}
},
{
timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: args.title }]
}
}
])
}
+296
View File
@@ -0,0 +1,296 @@
import { extname } from 'node:path'
import type {
AiVaultAgent,
AiVaultListResult,
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { sessionSortTime } from './session-scanner-accumulator'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { remoteSessionSources } from './remote-session-scanner-sources'
import type {
RemoteScannerContext,
RemoteSessionCandidate,
RemoteSessionSource
} from './remote-session-scanner-types'
const DEFAULT_REMOTE_SCAN_LIMIT = 1000
const REMOTE_SCAN_CONCURRENCY = 8
const REMOTE_SCOPE_PARSE_LIMIT = 2000
export async function scanRemoteAiVaultSessions(args: {
provider: IFilesystemProvider
executionHostId: ExecutionHostId
remoteHome: string
hostPlatform: RemoteHostPlatform
limit?: number
scopePaths?: readonly string[]
}): Promise<AiVaultListResult> {
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT
const issues: AiVaultScanIssue[] = []
const context: RemoteScannerContext = {
provider: args.provider,
executionHostId: args.executionHostId,
hostPlatform: args.hostPlatform,
titleCaches: new Map()
}
const candidates = (
await mapRemoteScanConcurrently(
remoteSessionSources(args.remoteHome, args.hostPlatform),
(source) => discoverRemoteSourceCandidates({ source, context, issues })
)
)
.flat()
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit })
const cappedSessions = parsed.sessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? [])
const parsedScopeSessions = parsed.sessions.filter((session) =>
isRemoteSessionInScope(session, scopePaths)
)
const extraScopeSessions = await scanRemoteInScopeSessions({
candidates,
context,
issues,
scopePaths,
alreadyParsedFilePaths: parsed.parsedFilePaths
})
return {
sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]),
issues,
scannedAt: new Date().toISOString()
}
}
async function discoverRemoteSourceCandidates(args: {
source: RemoteSessionSource
context: RemoteScannerContext
issues: AiVaultScanIssue[]
}): Promise<RemoteSessionCandidate[]> {
const paths = await walkRemoteSessionFiles(
args.source,
args.context.provider,
args.context.hostPlatform
)
const files = await mapRemoteScanConcurrently(paths, (path) =>
statRemoteFile(
args.context.provider,
path,
args.source.agent,
args.context.executionHostId,
args.issues
)
)
return files
.filter((file): file is FileWithMtime => Boolean(file))
.map((file) => ({ source: args.source, file }))
}
async function walkRemoteSessionFiles(
source: RemoteSessionSource,
provider: IFilesystemProvider,
hostPlatform: RemoteHostPlatform,
dirPath = source.rootDir
): Promise<string[]> {
let entries
try {
entries = await provider.readDir(dirPath)
} catch {
return []
}
const extensions = new Set(source.extensions)
const files: string[] = []
for (const entry of entries) {
const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name)
if (entry.isDirectory && !entry.isSymlink) {
files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath)))
continue
}
if (
!entry.isSymlink &&
extensions.has(extname(entry.name).toLowerCase()) &&
(source.filePredicate?.(fullPath) ?? true)
) {
files.push(fullPath)
}
}
return files
}
async function parseRemoteSessionCandidates(args: {
candidates: readonly RemoteSessionCandidate[]
context: RemoteScannerContext
issues: AiVaultScanIssue[]
limit: number
}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set<string> }> {
const sessions: AiVaultSession[] = []
const parsedFilePaths = new Set<string>()
let index = 0
while (index < args.candidates.length) {
if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
break
}
const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
for (const candidate of batch) {
parsedFilePaths.add(candidate.file.path)
}
const results = await Promise.all(
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
)
sessions.push(...results.filter(isAiVaultSession))
index += batch.length
}
return { sessions, parsedFilePaths }
}
async function scanRemoteInScopeSessions(args: {
candidates: readonly RemoteSessionCandidate[]
context: RemoteScannerContext
issues: AiVaultScanIssue[]
scopePaths: readonly string[]
alreadyParsedFilePaths: ReadonlySet<string>
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
return []
}
const candidates = args.candidates
.filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path))
.slice(0, REMOTE_SCOPE_PARSE_LIMIT)
const sessions: AiVaultSession[] = []
for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) {
const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
const results = await Promise.all(
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
)
sessions.push(
...results.filter(
(session): session is AiVaultSession =>
isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
)
)
}
return sessions
}
async function parseRemoteSessionCandidate(
candidate: RemoteSessionCandidate,
context: RemoteScannerContext,
issues: AiVaultScanIssue[]
): Promise<AiVaultSession | null> {
try {
const read = await context.provider.readFile(candidate.file.path)
if (read.isBinary) {
return null
}
return candidate.source.parse(candidate.file, read.content, context)
} catch (err) {
issues.push({
executionHostId: context.executionHostId,
agent: candidate.source.agent,
path: candidate.file.path,
message: errorMessage(err)
})
return null
}
}
function mergeRemoteSessions(
cappedSessions: AiVaultSession[],
scopeSessions: AiVaultSession[]
): AiVaultSession[] {
if (scopeSessions.length === 0) {
return cappedSessions
}
const byId = new Map<string, AiVaultSession>()
for (const session of cappedSessions) {
byId.set(session.id, session)
}
for (const session of scopeSessions) {
byId.set(session.id, session)
}
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
}
function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean {
const cwd = session.cwd
return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd)))
}
function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
}
async function statRemoteFile(
provider: IFilesystemProvider,
path: string,
agent: AiVaultAgent,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[]
): Promise<FileWithMtime | null> {
try {
const stat = await provider.stat(path)
const mtimeMs = remoteStatMtimeMs(stat)
return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
} catch (err) {
issues.push({ executionHostId, agent, path, message: errorMessage(err) })
return null
}
}
function remoteStatMtimeMs(stat: FileStat): number {
if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
return stat.mtimeMs
}
return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
}
function canStopParsingRemoteSessions(
sessions: AiVaultSession[],
limit: number,
nextCandidateMtimeMs: number | undefined
): boolean {
if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
return false
}
const visibleCutoff = sessions
.map(sessionSortTime)
.sort((left, right) => right - left)
.at(limit - 1)
// Transcript mtimes bound the remaining candidate order; once the visible
// cutoff is newer, older files cannot enter the unscoped top-N result.
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
}
function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
return Boolean(session)
}
async function mapRemoteScanConcurrently<T, U>(
items: readonly T[],
mapper: (item: T) => Promise<U>
): Promise<U[]> {
const results: U[] = []
for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) {
const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY)
results.push(...(await Promise.all(batch.map(mapper))))
}
return results
}
@@ -6,6 +6,7 @@ import {
type AiVaultSession,
type AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
extractPreviewContentText,
@@ -43,7 +44,11 @@ export function createAccumulator(args: {
export function finalizeSession(
accumulator: SessionAccumulator,
platform: NodeJS.Platform,
options: { codexHome?: string | null } = {}
options: {
codexHome?: string | null
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
} = {}
): AiVaultSession | null {
const sessionId = accumulator.sessionId.trim()
if (!sessionId) {
@@ -54,8 +59,14 @@ export function finalizeSession(
accumulator.fallbackTitle ||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
return {
id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
executionHostId,
...(options.executionHostPlatform
? { executionHostPlatform: options.executionHostPlatform }
: {}),
agent: accumulator.agent,
sessionId,
title,
@@ -1,11 +1,8 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseDevinSessionFile } from './session-scanner-devin-parser'
import { parseDroidSessionFile } from './session-scanner-droid-parser'
import { parseGrokSessionFile } from './session-scanner-grok-parser'
import {
parseDroidSessionFile,
parseMessageGraphSessionFile,
parseRovoSessionFile
} from './session-scanner-graph-parsers'
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
addPreviewContent,
createAccumulator,
@@ -35,21 +36,61 @@ const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTit
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null
codexHome: string | null = null,
executionHostId?: ExecutionHostId
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'codex',
file,
sessionId: sessionIdFromFileName(file.path)
})
let previousTotals: CodexUsageSnapshot | null = null
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
return parseCodexSessionLines({
file,
lines,
platform,
codexHome,
executionHostId,
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
})
}
export async function parseCodexSessionContent(args: {
file: FileWithMtime
content: string
platform?: NodeJS.Platform
codexHome?: string | null
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
readIndexedTitle?: (sessionId: string) => Promise<string | null>
}): Promise<AiVaultSession | null> {
return parseCodexSessionLines({
file: args.file,
lines: args.content.split(/\r?\n/),
platform: args.platform ?? process.platform,
codexHome: args.codexHome ?? null,
executionHostId: args.executionHostId,
executionHostPlatform: args.executionHostPlatform,
titleReader: args.readIndexedTitle
})
}
async function parseCodexSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
codexHome: string | null
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
titleReader?: (sessionId: string) => Promise<string | null>
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'codex',
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
let previousTotals: CodexUsageSnapshot | null = null
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -70,7 +111,7 @@ export async function parseCodexSessionFile(
}
const indexedTitle =
extractCodexSessionMetadataTitle(payload) ??
(await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId))
(await args.titleReader?.(accumulator.sessionId))
if (indexedTitle) {
accumulator.title = indexedTitle
}
@@ -158,7 +199,11 @@ export async function parseCodexSessionFile(
}
}
return finalizeSession(accumulator, platform, { codexHome })
return finalizeSession(accumulator, args.platform, {
codexHome: args.codexHome,
executionHostId: args.executionHostId,
executionHostPlatform: args.executionHostPlatform
})
}
function addCodexUsage(
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
import {
addPreviewContent,
@@ -17,11 +18,25 @@ import {
numberValue
} from './session-scanner-values'
type ParserSessionOptions = {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
}
export async function parseDevinSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform)
}
export function parseDevinSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
@@ -69,7 +84,7 @@ export async function parseDevinSessionFile(
)
}
}
return finalizeSession(accumulator, platform)
return finalizeSession(accumulator, platform, options)
}
function extractDevinStepText(step: Record<string, unknown>): string | null {
@@ -0,0 +1,120 @@
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewMessage,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
asRecord,
extractMessageText,
extractPreviewContentText,
extractString,
normalizeTitleText,
parseJsonObject,
tokenTotal
} from './session-scanner-values'
type ParserSessionOptions = {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
}
export async function parseDroidSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseDroidSessionLines({ file, lines, platform })
}
export async function parseDroidSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
return parseDroidSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseDroidSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'droid',
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, record.timestamp)
if (record.type === 'session_start') {
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
continue
}
if (record.type === 'system') {
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
accumulator.model = extractString(record.model) ?? accumulator.model
}
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
if (streamSessionId) {
accumulator.sessionId = streamSessionId
}
if (record.type === 'message') {
consumeDroidMessage(accumulator, record)
} else if (record.type === 'completion') {
accumulator.messageCount++
accumulator.totalTokens += tokenTotal(record.usage)
addPreviewMessage(accumulator, {
role: 'assistant',
text: extractString(record.finalText),
timestamp: record.timestamp
})
}
}
return finalizeSession(accumulator, args.platform, args.options)
}
function consumeDroidMessage(
accumulator: SessionAccumulator,
record: Record<string, unknown>
): void {
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
if (role !== 'user' && role !== 'assistant') {
return
}
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??=
normalizeTitleText(extractString(record.text) ?? '') ||
extractMessageText(asRecord(record.message))
}
addPreviewMessage(accumulator, {
role,
text:
extractString(record.text) ?? extractPreviewContentText(asRecord(record.message)?.content),
timestamp: record.timestamp
})
}
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
@@ -17,15 +18,18 @@ import {
asRecord,
extractContentText,
extractMessageText,
extractPreviewContentText,
extractString,
firstString,
normalizeTitleText,
parseJsonObject,
readJsonObjectIfExists,
tokenTotal
} from './session-scanner-values'
type ParserSessionOptions = {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
}
export async function parseRovoSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
@@ -159,17 +163,43 @@ export async function parseMessageGraphSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent,
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseMessageGraphSessionLines({ agent, file, lines, platform })
}
for await (const line of lines) {
export async function parseMessageGraphSessionContent(
agent: 'openclaw' | 'pi',
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionLines({
agent,
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseMessageGraphSessionLines(args: {
agent: 'openclaw' | 'pi'
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: args.agent,
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -204,69 +234,5 @@ export async function parseMessageGraphSessionFile(
}
}
return finalizeSession(accumulator, platform)
}
export async function parseDroidSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'droid',
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
continue
}
updateTimeline(accumulator, record.timestamp)
if (record.type === 'session_start') {
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
continue
}
if (record.type === 'system') {
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
accumulator.model = extractString(record.model) ?? accumulator.model
}
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
if (streamSessionId) {
accumulator.sessionId = streamSessionId
}
if (record.type === 'message') {
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
if (role === 'user' || role === 'assistant') {
accumulator.messageCount++
if (role === 'user') {
accumulator.title ??=
normalizeTitleText(extractString(record.text) ?? '') ||
extractMessageText(asRecord(record.message))
}
addPreviewMessage(accumulator, {
role,
text:
extractString(record.text) ??
extractPreviewContentText(asRecord(record.message)?.content),
timestamp: record.timestamp
})
}
} else if (record.type === 'completion') {
accumulator.messageCount++
accumulator.totalTokens += tokenTotal(record.usage)
addPreviewMessage(accumulator, {
role: 'assistant',
text: extractString(record.finalText),
timestamp: record.timestamp
})
}
}
return finalizeSession(accumulator, platform)
return finalizeSession(accumulator, args.platform, args.options)
}
@@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
@@ -23,24 +24,51 @@ import {
tokenTotal
} from './session-scanner-values'
type ParserSessionOptions = {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
}
export async function parseClaudeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'claude',
file,
sessionId: sessionIdFromFileName(file.path)
})
let metaTitle: string | null = null
let generatedTitle: string | null = null
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseClaudeSessionLines({ file, lines, platform })
}
for await (const line of lines) {
export async function parseClaudeSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
return parseClaudeSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseClaudeSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'claude',
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
let metaTitle: string | null = null
let generatedTitle: string | null = null
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -92,7 +120,7 @@ export async function parseClaudeSessionFile(
}
accumulator.fallbackTitle = generatedTitle ?? metaTitle
return finalizeSession(accumulator, platform)
return finalizeSession(accumulator, args.platform, args.options)
}
export async function parseGeminiSessionFile(
@@ -103,7 +131,33 @@ export async function parseGeminiSessionFile(
return parseGeminiJsonlSessionFile(file, platform)
}
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
}
export async function parseGeminiSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
return parseGeminiJsonSessionContent(file, content, platform, options)
}
function parseGeminiJsonSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ParserSessionOptions = {}
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
@@ -117,24 +171,33 @@ export async function parseGeminiSessionFile(
for (const message of arrayValue(record.messages)) {
consumeGeminiMessage(accumulator, asRecord(message))
}
return finalizeSession(accumulator, platform)
return finalizeSession(accumulator, platform, options)
}
export async function parseGeminiJsonlSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'gemini',
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseGeminiJsonlSessionLines({ file, lines, platform })
}
for await (const line of lines) {
async function parseGeminiJsonlSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'gemini',
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -153,7 +216,7 @@ export async function parseGeminiJsonlSessionFile(
consumeGeminiMessage(accumulator, record)
}
return finalizeSession(accumulator, platform)
return finalizeSession(accumulator, args.platform, args.options)
}
export function consumeGeminiMessage(
@@ -3,6 +3,7 @@ import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import {
addPreviewContent,
@@ -29,21 +30,49 @@ import {
tokenTotal
} from './session-scanner-values'
type ParserSessionOptions = {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
}
export async function parseCopilotSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'copilot',
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseCopilotSessionLines({ file, lines, platform })
}
for await (const line of lines) {
export async function parseCopilotSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
return parseCopilotSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseCopilotSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'copilot',
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -94,24 +123,47 @@ export async function parseCopilotSessionFile(
}
}
return finalizeSession(accumulator, platform)
return finalizeSession(accumulator, args.platform, args.options)
}
export async function parseCursorSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'cursor',
file,
sessionId: sessionIdFromFileName(file.path)
})
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseCursorSessionLines({ file, lines, platform })
}
for await (const line of lines) {
export async function parseCursorSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
return parseCursorSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseCursorSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const accumulator = createAccumulator({
agent: 'cursor',
file: args.file,
sessionId: sessionIdFromFileName(args.file.path)
})
for await (const line of args.lines) {
const record = parseJsonObject(line)
if (!record) {
continue
@@ -132,7 +184,7 @@ export async function parseCursorSessionFile(
)
}
}
return finalizeSession(accumulator, platform)
return finalizeSession(accumulator, args.platform, args.options)
}
export async function parseOpenCodeSessionFile(
@@ -207,7 +259,16 @@ export async function parseHermesSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform)
}
export async function parseHermesSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
@@ -234,5 +295,5 @@ export async function parseHermesSessionFile(
if (accumulator.messageCount === 0) {
accumulator.messageCount = numberValue(record.message_count)
}
return finalizeSession(accumulator, platform)
return finalizeSession(accumulator, platform, options)
}
@@ -4,6 +4,7 @@ import type {
AiVaultSession,
AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
export type AiVaultScanOptions = {
claudeProjectsDir?: string
@@ -33,6 +34,7 @@ export type AiVaultScanOptions = {
// the recency cap (see discoverInScopeClaudeFiles).
scopePaths?: readonly string[]
platform?: NodeJS.Platform
executionHostId?: ExecutionHostId
}
export type FileWithMtime = {
+32 -4
View File
@@ -3,6 +3,7 @@ import type {
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { sessionSortTime } from './session-scanner-accumulator'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
@@ -41,6 +42,7 @@ export async function scanAiVaultSessions(
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
const platform = options.platform ?? process.platform
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
@@ -63,6 +65,7 @@ export async function scanAiVaultSessions(
candidates,
limit,
platform,
executionHostId,
issues
})
@@ -75,12 +78,13 @@ export async function scanAiVaultSessions(
scopePaths: options.scopePaths ?? [],
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
executionHostId,
issues
})
return {
sessions: mergeSessions(cappedSessions, scopeSessions),
issues,
issues: issues.map((issue) => ({ executionHostId, ...issue })),
scannedAt: new Date().toISOString()
}
}
@@ -110,6 +114,7 @@ async function scanInScopeSessions(args: {
scopePaths: readonly string[]
alreadyParsedFilePaths: ReadonlySet<string>
platform: NodeJS.Platform
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
@@ -136,6 +141,7 @@ async function scanInScopeSessions(args: {
candidates,
limit: candidates.length,
platform: args.platform,
executionHostId: args.executionHostId,
issues: args.issues
})
}
@@ -144,6 +150,7 @@ async function parseSessionCandidates(args: {
candidates: SessionFileCandidate[]
limit: number
platform: NodeJS.Platform
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
const sessions: AiVaultSession[] = []
@@ -159,7 +166,9 @@ async function parseSessionCandidates(args: {
const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining)
const batch = args.candidates.slice(index, index + batchSize)
const results = await Promise.all(
batch.map((candidate) => parseSessionCandidate(candidate, args.platform))
batch.map((candidate) =>
parseSessionCandidate(candidate, args.platform, args.executionHostId)
)
)
for (const result of results) {
@@ -179,15 +188,20 @@ async function parseSessionCandidates(args: {
async function parseSessionCandidate(
candidate: SessionFileCandidate,
platform: NodeJS.Platform
platform: NodeJS.Platform,
executionHostId: ExecutionHostId
): Promise<SessionParseResult> {
try {
const session = await parseAgentSessionFile(candidate, platform)
return { session, issue: null }
return {
session: session ? withSessionExecutionHost(session, executionHostId) : null,
issue: null
}
} catch (err) {
return {
session: null,
issue: {
executionHostId,
agent: candidate.agent,
path: candidate.file.path,
message: errorMessage(err)
@@ -196,6 +210,20 @@ async function parseSessionCandidate(
}
}
function withSessionExecutionHost(
session: AiVaultSession,
executionHostId: ExecutionHostId
): AiVaultSession {
if (session.executionHostId === executionHostId) {
return session
}
return {
...session,
executionHostId,
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
}
}
function canStopParsingSessions(
sessions: AiVaultSession[],
limit: number,
@@ -127,6 +127,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
+2
View File
@@ -131,6 +131,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
+17
View File
@@ -172,6 +172,23 @@ export class HeadlessEmulator {
return this.terminal.buffer.active.type === 'alternate'
}
/** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with
* pending input it re-renders at a cached buffer row that ConPTY's fixed
* viewport doesn't track, painting the input well below the prompt. The
* cursor line counts as an empty prompt when everything before the cursor
* ends with a single '>' and nothing follows it ('>>' is PowerShell's
* continuation prompt, i.e. a multiline edit in flight). */
isCursorOnEmptyPromptLine(): boolean {
const buffer = this.terminal.buffer.active
const line = buffer.getLine(buffer.baseY + buffer.cursorY)
if (!line) {
return false
}
const upToCursor = line.translateToString(true, 0, buffer.cursorX).trimEnd()
const fullLine = line.translateToString(true).trimEnd()
return fullLine === upToCursor && upToCursor.endsWith('>') && !upToCursor.endsWith('>>')
}
getVisibleLines(): string[] {
const buffer = this.terminal.buffer.active
const lines: string[] = []
+97
View File
@@ -527,6 +527,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
cwd: 'C:\\repo\\orca',
command: 'codex'
})
@@ -1038,6 +1039,97 @@ describe('createPtySubprocess', () => {
expect(shellArg.length).toBeGreaterThan(0)
})
it('allows an explicitly requested plain daemon shell at POSIX root', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
try {
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, cwd: '/' })
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ cwd: '/' })
)
})
it('rejects daemon automatic agent startup without an explicit cwd', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
spawnMock.mockClear()
try {
expect(() =>
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
command: 'codex'
})
).toThrow(/requires a non-root workspace/)
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects daemon automatic agent startup at POSIX root', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
spawnMock.mockClear()
try {
expect(() =>
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '/',
command: 'claude'
})
).toThrow(/requires a non-root workspace/)
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects a missing explicit POSIX cwd before node-pty spawn', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
spawnMock.mockClear()
try {
expect(() =>
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '/definitely-missing-orca-cwd'
})
).toThrow(/definitely-missing-orca-cwd/)
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).not.toHaveBeenCalled()
})
it('passes custom env to spawned process', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@@ -1238,6 +1330,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '/repo',
command: 'codex',
env: { SHELL: '/bin/zsh' }
})
@@ -1264,6 +1357,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '/repo',
command: "codex 'linked issue context'",
startupCommandDelivery: 'shell-ready',
env: { SHELL: '/bin/zsh' }
@@ -1291,6 +1385,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '/repo',
command: "codex --prefill 'linked issue context'",
env: { SHELL: '/bin/zsh' }
})
@@ -1560,6 +1655,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
cwd: 'C:\\repo\\orca',
shellOverride: 'powershell.exe',
command: "& 'codex' '--no-alt-screen'"
})
@@ -1589,6 +1685,7 @@ describe('createPtySubprocess', () => {
sessionId: 'test',
cols: 80,
rows: 24,
cwd: 'C:\\repo\\orca',
shellOverride: 'cmd.exe',
command: `codex ${'x'.repeat(7000)}`
})
+33 -19
View File
@@ -54,6 +54,7 @@ import {
import { isShellProcess } from '../../shared/shell-process-detection'
import { parsePtySessionId } from './pty-session-id'
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -93,20 +94,7 @@ export type PtySubprocessOptions = {
* Returns a stable default working directory for daemon-spawned PTYs.
*/
function getDefaultCwd(): string {
if (process.platform !== 'win32') {
return process.env.HOME || '/'
}
// Why: HOMEPATH alone is drive-relative (`\\Users\\name`). Pair it with
// HOMEDRIVE when USERPROFILE is unavailable so daemon-spawned Windows PTYs
// still start in a valid absolute home directory.
if (process.env.USERPROFILE) {
return process.env.USERPROFILE
}
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
}
return 'C:\\'
return resolveSafePtyDefaultCwd()
}
/**
@@ -206,11 +194,13 @@ function isExistingDirectory(path: string | undefined): path is string {
* Moves the daemon process to a stable cwd after its original cwd disappears.
*/
function repairDaemonCwd(): string | null {
const candidates = [
process.env.ORCA_USER_DATA_PATH,
getDefaultCwd(),
process.platform === 'win32' ? 'C:\\' : '/'
]
const candidates = [process.env.ORCA_USER_DATA_PATH]
try {
candidates.push(getDefaultCwd())
} catch {
// Keep daemon cwd repair best-effort even when no user terminal cwd is safe.
}
candidates.push(process.platform === 'win32' ? 'C:\\' : '/')
for (const candidate of candidates) {
if (isExistingDirectory(candidate)) {
try {
@@ -314,6 +304,16 @@ function preflightWindowsPtySpawnEnvironment(args: {
validateWorkingDirectory(args.validationCwd)
}
/**
* Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT.
*/
function preflightPosixPtySpawnEnvironment(validationCwd: string): void {
if (process.platform === 'win32') {
return
}
validateWorkingDirectory(validationCwd)
}
/**
* Wraps native PTY spawn failures with shell and cwd context.
*/
@@ -589,6 +589,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
if (opts.command && startupAgentRecognition) {
assertSafeAgentStartupCwd(opts.cwd, opts.command)
}
const requestedCwd = opts.cwd || getDefaultCwd()
let spawnCwd = requestedCwd
let validationCwd = spawnCwd
@@ -772,6 +775,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// runs in a separate forked process with its own code path.
ensureNodePtySpawnHelperExecutable()
preflightUnixPtySpawnEnvironment()
preflightPosixPtySpawnEnvironment(validationCwd)
preflightWindowsPtySpawnEnvironment({
validationCwd,
cwdWasExplicit: opts.cwd !== undefined
@@ -1026,6 +1030,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
dead = true
}
},
clear: () => {
if (dead) {
return
}
try {
proc.clear()
} catch {
// Best-effort: a clear on a just-exited PTY must not kill the handle.
}
},
kill: () => {
if (dead) {
return
+101 -1
View File
@@ -9,6 +9,7 @@ function createMockSubprocess() {
let onData: ((data: string) => void) | null = null
let onExit: ((code: number) => void) | null = null
let killed = false
let clearCalls = 0
let pid = 12345
return {
@@ -20,13 +21,20 @@ function createMockSubprocess() {
get pid() {
return pid
},
foregroundProcess: null as string | null,
getForegroundProcess(): string | null {
return null
return this.foregroundProcess
},
write(data: string) {
written.push(data)
},
resize(_cols: number, _rows: number) {},
get clearCalls() {
return clearCalls
},
clear() {
clearCalls++
},
kill() {
killed = true
// Simulate async exit
@@ -426,6 +434,98 @@ describe('Session', () => {
})
})
describe('clearScrollback', () => {
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const original = process.platform
Object.defineProperty(process, 'platform', { value: platform })
try {
run()
} finally {
Object.defineProperty(process, 'platform', { value: original })
}
}
it('resyncs the native PTY screen state alongside the emulator clear', () => {
createSession()
session.clearScrollback()
// Why: without the subprocess clear, ConPTY keeps a stale cursor row and
// the next prompt repaint lands below a blank gap on Windows.
expect(subprocess.clearCalls).toBe(1)
const take = session.takePendingOutput(false)
expect(take?.records).toContainEqual({ kind: 'clear' })
})
it('nudges a Windows PowerShell prompt to repaint with a form feed', async () => {
createSession()
subprocess.foregroundProcess = 'powershell.exe'
subprocess.simulateData('PS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('win32', () => session.clearScrollback())
// Why: the ConPTY clear cannot reach PSReadLine's cached cursor row;
// Ctrl+L makes PSReadLine repaint the prompt at the true origin.
expect(subprocess.written).toEqual(['\x0c'])
})
it('does not send a form feed while input is pending at the prompt', async () => {
createSession()
subprocess.foregroundProcess = 'powershell.exe'
subprocess.simulateData('PS C:\\Users\\me> fd')
await vi.advanceTimersByTimeAsync(10)
// Why: PSReadLine repaints pending input at a stale cached row that
// ConPTY's fixed viewport doesn't track, so the nudge must be skipped.
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
})
it('does not send or queue a form feed before shell-ready', async () => {
createSession({ shellReadySupported: true })
subprocess.foregroundProcess = 'powershell.exe'
subprocess.simulateData('PS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
// Why: a queued form feed would flush after the startup command at an
// arbitrary later moment, when the prompt gates no longer hold.
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nPS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
expect(subprocess.written).toEqual([])
})
it('does not send a form feed at a PowerShell continuation prompt', async () => {
createSession()
subprocess.foregroundProcess = 'powershell.exe'
subprocess.simulateData('PS C:\\Users\\me> {\r\n>> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
})
it('does not send a form feed while a command owns the foreground', async () => {
createSession()
subprocess.foregroundProcess = 'node'
subprocess.simulateData('PS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
})
it('does not send a form feed on POSIX platforms', async () => {
createSession()
subprocess.foregroundProcess = 'pwsh'
subprocess.simulateData('PS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('linux', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
})
it('does not touch the subprocess after dispose', () => {
createSession()
session.dispose()
session.clearScrollback()
expect(subprocess.clearCalls).toBe(0)
})
})
describe('snapshot', () => {
it('returns a terminal snapshot', async () => {
createSession()
+34
View File
@@ -8,6 +8,7 @@ import {
scanForShellReady,
type ShellReadyScanState
} from '../shell-ready-marker-scanner'
import { isPowerShellProcess } from '../../shared/shell-process-detection'
import type {
PendingOutputRecord,
SessionState,
@@ -41,6 +42,10 @@ export type SubprocessHandle = {
startupCommandDeliveredInShellArgs?: boolean
write(data: string): void
resize(cols: number, rows: number): void
/** Resync the native PTY's own screen state after a frontend clear.
* No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes
* the next prompt repaint land below a blank gap. */
clear?(): void
kill(): void
forceKill(): void
signal(sig: string): void
@@ -285,6 +290,35 @@ export class Session {
}
this.emulator.clearScrollback()
this.recordPendingOutput({ kind: 'clear' })
this.subprocess.clear?.()
this.#nudgePowerShellPromptRepaint()
}
/** Why: ConPTY's buffer clear cannot reach PSReadLine's cached cursor row,
* so PowerShell's first Enter after a clear would still repaint the prompt
* at the stale row, leaving a blank gap. A form feed (Ctrl+L) makes
* PSReadLine itself repaint at the true origin. Gated to a PowerShell
* foreground so a running command or TUI never gets a stray 0x0C, and to
* an empty prompt because PSReadLine repaints pending input at a stale
* cached row that ConPTY's fixed viewport doesn't track. */
#nudgePowerShellPromptRepaint(): void {
if (process.platform !== 'win32') {
return
}
// Why: before shell-ready, write() would queue the form feed behind the
// buffered startup command and deliver it at an arbitrary later moment,
// when the foreground/prompt gates below no longer hold. The nudge is
// cosmetic — skip it rather than defer it.
if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
return
}
if (!isPowerShellProcess(this.subprocess.getForegroundProcess())) {
return
}
if (!this.emulator.isCursorOnEmptyPromptLine()) {
return
}
this.subprocess.write('\x0c')
}
prepareForFinalSnapshot(): string {
+68
View File
@@ -10,6 +10,7 @@ import {
getBranchConflictKind,
getRemoteCount,
parseAndFilterSearchRefDetails,
resolveDefaultBaseRefViaExec,
searchBaseRefDetails,
searchBaseRefs
} from './repo'
@@ -493,6 +494,26 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
expect(result).toBe('origin/main')
})
it('falls through from a stale origin/HEAD target to an existing primary ref', () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/main', sha)
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master'])
const result = getDefaultBaseRef(tmpDir)
expect(result).toBe('origin/main')
})
it('falls through from a stale origin/HEAD primary target to another existing default ref', () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/master', sha)
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'])
const result = getDefaultBaseRef(tmpDir)
expect(result).toBe('origin/master')
})
it('does NOT fall through to upstream/main when origin/* is absent', () => {
// Why: this is the explicit design decision — the default probe order
// is origin-only. upstream-aware defaulting is deferred work.
@@ -508,6 +529,53 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
})
})
describe('resolveDefaultBaseRefViaExec', () => {
it('falls through from a stale origin/HEAD target to the probe list', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/master\n' }
}
if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/main') {
return { stdout: 'main-sha\n' }
}
throw new Error('missing ref')
}
await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/main')
expect(calls).toEqual([
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main']
])
})
it('verifies origin/HEAD even when it points at origin/main', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n' }
}
if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/master') {
return { stdout: 'master-sha\n' }
}
throw new Error('missing ref')
}
await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/master')
expect(calls).toEqual([
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master']
])
})
})
describe('getRemoteCount', () => {
let tmpDir: string
+47 -27
View File
@@ -467,6 +467,24 @@ function hasGitRef(path: string, ref: string): boolean {
}
}
function gitRefToDefaultBaseRef(ref: string): string {
return ref.replace(/^refs\/remotes\//, '')
}
function getVerifiedOriginHeadBaseRef(path: string): string | null {
try {
const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
cwd: path
}).trim()
// Why: origin/HEAD may survive a default-branch rename while pointing at a
// deleted ref; verify before trusting it over the probe list.
return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null
} catch {
return null
}
}
/**
* Resolve the default base ref for new worktrees.
* Prefer the remote primary branch over a potentially stale local branch.
@@ -478,16 +496,9 @@ function hasGitRef(path: string, ref: string): boolean {
* degrade gracefully for non-creation uses (e.g. hosted URL building).
*/
export function getDefaultBaseRef(path: string): string | null {
try {
const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
cwd: path
}).trim()
if (ref) {
return ref.replace(/^refs\/remotes\//, '')
}
} catch {
// Fall through to explicit remote branch probes.
const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path)
if (originHeadBaseRef) {
return originHeadBaseRef
}
// Why: walk the shared DEFAULT_BASE_REF_PROBES list so the sync path and the
@@ -595,6 +606,28 @@ export async function getRemoteCount(path: string): Promise<number> {
/** Callback shape for a git exec function that yields stdout. */
export type GitExec = (argv: string[]) => Promise<{ stdout: string }>
async function hasGitRefViaExec(exec: GitExec, ref: string): Promise<boolean> {
try {
await exec(['rev-parse', '--verify', '--quiet', ref])
return true
} catch {
return false
}
}
async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise<string | null> {
try {
const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
const ref = stdout.trim()
if (!ref || !(await hasGitRefViaExec(exec, ref))) {
return null
}
return gitRefToDefaultBaseRef(ref)
} catch {
return null
}
}
/**
* Resolve the default base ref given a git exec callback. Prefers
* origin/HEAD's symbolic-ref target; falls back to DEFAULT_BASE_REF_PROBES.
@@ -609,24 +642,11 @@ export type GitExec = (argv: string[]) => Promise<{ stdout: string }>
* from a genuine transport failure.
*/
export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise<string | null> {
try {
const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
const ref = stdout.trim()
if (ref) {
return ref.replace(/^refs\/remotes\//, '')
}
} catch {
// symbolic-ref returns non-zero when origin/HEAD is unset — expected.
// Fall through to probes.
const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec)
if (originHeadBaseRef) {
return originHeadBaseRef
}
return resolveDefaultBaseRefFromProbes(async (ref) => {
try {
await exec(['rev-parse', '--verify', '--quiet', ref])
return true
} catch {
return false
}
})
return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref))
}
async function getDefaultBaseRefAsync(
+8 -5
View File
@@ -10,11 +10,14 @@ export async function hasWorktreeBaseCommitRef(
options: GitExecOptions = {}
): Promise<boolean> {
try {
await gitExecFileAsync(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], {
cwd: repoPath,
...options
})
return true
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
{
cwd: repoPath,
...options
}
)
return stdout.trim().length > 0
} catch {
return false
}
+13
View File
@@ -20,6 +20,7 @@ import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
import { killAllPty } from './ipc/pty'
import { installRelocatedNodePtyNativeRuntime } from './pty/node-pty-runtime-relocation'
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
@@ -99,6 +100,7 @@ import {
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import { RateLimitService } from './rate-limits/service'
import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
import {
@@ -574,6 +576,9 @@ if (hasSingleInstanceLock) {
initClaudeUsagePath()
initCodexUsagePath()
initOpenCodeUsagePath()
// Why: must run before anything loads node-pty (first PTY spawn) so main
// and the daemon it forks both pick up ORCA_NODE_PTY_NATIVE_DIR.
installRelocatedNodePtyNativeRuntime()
crashReports = CrashReportStore.fromUserData()
recordCrashBreadcrumb('app_started', {
packaged: app.isPackaged,
@@ -1685,6 +1690,14 @@ app.whenReady().then(async () => {
workspaceIdOverride: settings.opencodeWorkspaceId
}
})
rateLimits.setMiniMaxConfigResolver(() => {
const settings = store!.getSettings()
return {
sessionCookie: readMiniMaxSessionCookie() ?? '',
groupId: settings.minimaxGroupId,
models: settings.minimaxUsageModels
}
})
rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled)
rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings())
keybindings = new KeybindingService({
+163
View File
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import type { IFilesystemProvider } from '../providers/types'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
const mocks = vi.hoisted(() => ({
scanAiVaultSessions: vi.fn(),
scanRemoteAiVaultSessions: vi.fn(),
getAiVaultWslHomeDirs: vi.fn(),
getSshFilesystemProvider: vi.fn(),
getActiveSshAiVaultHostInfo: vi.fn(),
getActiveSshAiVaultHostInfos: vi.fn()
}))
vi.mock('electron', () => ({
app: { on: vi.fn() },
ipcMain: { handle: vi.fn() }
}))
vi.mock('../ai-vault/session-scanner', () => ({
scanAiVaultSessions: mocks.scanAiVaultSessions
}))
vi.mock('../ai-vault/remote-session-scanner', () => ({
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
}))
vi.mock('../wsl', () => ({
getWslHomeAsync: mocks.getAiVaultWslHomeDirs,
listWslDistrosAsync: vi.fn().mockResolvedValue([])
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
getSshFilesystemProvider: mocks.getSshFilesystemProvider
}))
vi.mock('./ssh', () => ({
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
}))
const { _internals } = await import('./ai-vault')
const provider = {} as IFilesystemProvider
beforeEach(() => {
vi.clearAllMocks()
_internals.resetAiVaultCacheForTests()
mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')]))
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
result([session('ssh:dev-box', 'remote-session')])
)
mocks.getSshFilesystemProvider.mockReturnValue(provider)
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
})
describe('listAiVaultSessions host routing', () => {
it('routes local scope to the local scanner', async () => {
await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] })
expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith(
expect.objectContaining({
scopePaths: ['/repo'],
executionHostId: 'local'
})
)
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
})
it('routes SSH scope to only that SSH target', async () => {
await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box',
scopePaths: ['/home/ada/repo']
})
expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled()
expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box')
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith(
expect.objectContaining({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
scopePaths: ['/home/ada/repo']
})
)
})
it('merges local plus connected SSH targets for all hosts', async () => {
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
})
it('returns a scan issue for a disconnected SSH target', async () => {
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null)
mocks.getSshFilesystemProvider.mockReturnValue(undefined)
const result = await _internals.listAiVaultSessions({
executionHostScope: 'ssh:disconnected'
})
expect(result.sessions).toEqual([])
expect(result.issues).toMatchObject([
{
executionHostId: 'ssh:disconnected',
agent: 'codex',
path: 'disconnected'
}
])
})
it('keeps host scope in the cache key', async () => {
await _internals.listAiVaultSessions({ executionHostScope: 'local' })
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
})
})
function hostInfo(targetId: string) {
return {
targetId,
executionHostId: `ssh:${targetId}` as const,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
}
}
function result(sessions: AiVaultSession[]): AiVaultListResult {
return { sessions, issues: [], scannedAt: new Date().toISOString() }
}
function session(
executionHostId: AiVaultSession['executionHostId'],
sessionId: string
): AiVaultSession {
return {
id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`,
executionHostId,
agent: 'codex',
sessionId,
title: sessionId,
cwd: '/repo',
branch: null,
model: null,
filePath: `/tmp/${sessionId}.jsonl`,
codexHome: null,
createdAt: null,
updatedAt:
sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z',
modifiedAt: '2026-07-04T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
resumeCommand: `codex resume ${sessionId}`
}
}
+160 -14
View File
@@ -1,8 +1,26 @@
import { app, ipcMain } from 'electron'
import { join } from 'node:path'
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
import { sessionSortTime } from '../ai-vault/session-scanner-accumulator'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import type {
AiVaultListArgs,
AiVaultListResult,
AiVaultScanIssue
} from '../../shared/ai-vault-types'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostScope,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostScope
} from '../../shared/execution-host'
import {
getSshFilesystemProvider,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-filesystem-dispatch'
import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
const AI_VAULT_CACHE_TTL_MS = 15_000
@@ -22,10 +40,14 @@ let inflightKey: string | null = null
let handlerOptions: AiVaultHandlerOptions = {}
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
const executionHostScope = normalizeExecutionHostScope(
args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
)
// Scope paths change the result set, so they must be part of the cache key.
const key = JSON.stringify({
limit: args?.limit ?? 'default',
scopePaths: args?.scopePaths ?? []
scopePaths: args?.scopePaths ?? [],
executionHostScope
})
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
@@ -38,16 +60,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
}
inflightKey = key
const additionalCodexSessionsDirs =
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
[]
inflightList = (async () =>
scanAiVaultSessions({
limit: args?.limit,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs()
}))()
inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope)
.then((result) => {
cachedList = {
key,
@@ -57,12 +70,133 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
return result
})
.finally(() => {
inflightKey = null
inflightList = null
// Only clear tracking if it still refers to this request: a concurrent
// different-scope scan may have replaced it and must stay dedupable.
if (inflightKey === key) {
inflightKey = null
inflightList = null
}
})
return inflightList
}
async function scanAiVaultSessionsByHostScope(
args: AiVaultListArgs | undefined,
executionHostScope: ExecutionHostScope
): Promise<AiVaultListResult> {
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
return scanLocalAiVaultSessions(args)
}
if (executionHostScope === 'all') {
return mergeAiVaultListResults(
await Promise.all([
scanLocalAiVaultSessions(args),
...getActiveSshAiVaultHostInfos().map((hostInfo) =>
scanSshAiVaultSessions(hostInfo.targetId, args)
)
]),
args?.limit
)
}
const parsed = parseExecutionHostId(executionHostScope)
if (parsed?.kind === 'ssh') {
return scanSshAiVaultSessions(parsed.targetId, args)
}
return {
sessions: [],
issues: [
{
executionHostId: executionHostScope,
agent: 'codex',
path: executionHostScope,
message: 'Agent Session History is not available for this execution host.'
}
],
scannedAt: new Date().toISOString()
}
}
async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
const additionalCodexSessionsDirs =
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
[]
return scanAiVaultSessions({
limit: args?.limit,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs(),
executionHostId: LOCAL_EXECUTION_HOST_ID
})
}
async function scanSshAiVaultSessions(
targetId: string,
args?: AiVaultListArgs
): Promise<AiVaultListResult> {
const executionHostId = toSshExecutionHostId(targetId)
const hostInfo = getActiveSshAiVaultHostInfo(targetId)
const provider = getSshFilesystemProvider(targetId)
if (!hostInfo || !provider) {
return sshScanIssueResult({
executionHostId,
targetId,
message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
})
}
return scanRemoteAiVaultSessions({
provider,
executionHostId: hostInfo.executionHostId,
remoteHome: hostInfo.remoteHome,
hostPlatform: hostInfo.hostPlatform,
limit: args?.limit,
scopePaths: args?.scopePaths
})
}
function sshScanIssueResult(args: {
executionHostId: `ssh:${string}`
targetId: string
message: string
}): AiVaultListResult {
return {
sessions: [],
issues: [
{
executionHostId: args.executionHostId,
agent: 'codex',
path: args.targetId,
message: args.message
}
],
scannedAt: new Date().toISOString()
}
}
function mergeAiVaultListResults(
results: readonly AiVaultListResult[],
rawLimit: number | undefined
): AiVaultListResult {
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
const byId = new Map<string, AiVaultListResult['sessions'][number]>()
const issues: AiVaultScanIssue[] = []
for (const result of results) {
for (const session of result.sessions) {
byId.set(session.id, session)
}
issues.push(...result.issues)
}
return {
sessions: [...byId.values()]
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit),
issues,
scannedAt: new Date().toISOString()
}
}
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
handlerOptions = options
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
@@ -77,6 +211,18 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
})
}
function resetAiVaultCacheForTests(): void {
cachedList = null
inflightList = null
inflightKey = null
handlerOptions = {}
}
export const _internals = {
listAiVaultSessions,
resetAiVaultCacheForTests
}
async function getAiVaultWslHomeDirs(): Promise<string[]> {
if (process.platform !== 'win32') {
return []
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const ipcState = vi.hoisted(() => ({
handleHandlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
ipcState.handleHandlers.set(channel, handler)
}
}
}))
const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false))
const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
vi.mock('../minimax/minimax-cookie-store', () => ({
saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock,
clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock,
hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock
}))
vi.mock('../rate-limits/minimax-request-context', () => ({
clearMiniMaxSessionCookieJar: clearMiniMaxSessionCookieJarMock
}))
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
import type { RateLimitService } from '../rate-limits/service'
import type { RateLimitState } from '../../shared/rate-limit-types'
function makeRefreshMock(): {
refresh: ReturnType<typeof vi.fn>
invalidateMiniMaxCredentialState: ReturnType<typeof vi.fn>
service: Pick<RateLimitService, 'refresh' | 'invalidateMiniMaxCredentialState'>
} {
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
const invalidateMiniMaxCredentialState = vi.fn()
return {
refresh,
invalidateMiniMaxCredentialState,
service: { refresh, invalidateMiniMaxCredentialState }
}
}
async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
const handler = ipcState.handleHandlers.get(channel)
if (!handler) {
throw new Error(`No handler registered for ${channel}`)
}
return (await handler({}, ...args)) as T
}
describe('registerMiniMaxCredentialsHandlers', () => {
beforeEach(() => {
ipcState.handleHandlers.clear()
saveMiniMaxSessionCookieMock.mockReset()
clearMiniMaxSessionCookieMock.mockReset()
clearMiniMaxSessionCookieJarMock.mockReset()
clearMiniMaxSessionCookieJarMock.mockResolvedValue(undefined)
hasMiniMaxSessionCookieMock.mockReset()
hasMiniMaxSessionCookieMock.mockReturnValue(false)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('registers the three MiniMax credential channels', () => {
registerMiniMaxCredentialsHandlers(null)
expect(ipcState.handleHandlers.has('minimaxCredentials:getStatus')).toBe(true)
expect(ipcState.handleHandlers.has('minimaxCredentials:saveCookie')).toBe(true)
expect(ipcState.handleHandlers.has('minimaxCredentials:clearCookie')).toBe(true)
})
it('returns the configured state on getStatus from the cookie store', async () => {
hasMiniMaxSessionCookieMock.mockReturnValue(true)
registerMiniMaxCredentialsHandlers(null)
const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus')
expect(status).toEqual({ configured: true })
})
it('persists the cookie and reports configured after saveCookie', async () => {
hasMiniMaxSessionCookieMock.mockReturnValueOnce(true)
registerMiniMaxCredentialsHandlers(null)
const status = await invoke<{ configured: boolean }>(
'minimaxCredentials:saveCookie',
'_token=abc; minimax_group_id_v2=42'
)
expect(saveMiniMaxSessionCookieMock).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
expect(status).toEqual({ configured: true })
})
it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => {
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
registerMiniMaxCredentialsHandlers(service as RateLimitService)
await invoke('minimaxCredentials:saveCookie', '_token=abc')
// Why: the save handler is fire-and-forget — wait a microtask cycle so
// the queued `void rateLimits?.refresh()` resolves before we assert.
await new Promise((resolve) => setImmediate(resolve))
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
expect(refresh).toHaveBeenCalledTimes(1)
})
it('does not throw when saveCookie runs without a rate-limit service', async () => {
registerMiniMaxCredentialsHandlers(null)
await expect(invoke('minimaxCredentials:saveCookie', '_token=abc')).resolves.toBeDefined()
})
it('clears the cookie and triggers a refresh on clearCookie', async () => {
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
registerMiniMaxCredentialsHandlers(service as RateLimitService)
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
expect(status).toEqual({ configured: false })
await new Promise((resolve) => setImmediate(resolve))
expect(refresh).toHaveBeenCalledTimes(1)
})
it('still refreshes and reports cleared when session jar cleanup rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
clearMiniMaxSessionCookieJarMock.mockRejectedValueOnce(new Error('jar boom'))
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
registerMiniMaxCredentialsHandlers(service as RateLimitService)
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
expect(status).toEqual({ configured: false })
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('failed to clear session cookie jar after credential clear'),
expect.any(Error)
)
await new Promise((resolve) => setImmediate(resolve))
expect(refresh).toHaveBeenCalledTimes(1)
})
it('logs but does not throw when the post-save rate-limit refresh rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const refresh = vi.fn(() => Promise.reject(new Error('refresh boom')))
const invalidateMiniMaxCredentialState = vi.fn()
registerMiniMaxCredentialsHandlers({
refresh,
invalidateMiniMaxCredentialState
} as Pick<RateLimitService, 'refresh' | 'invalidateMiniMaxCredentialState'> as RateLimitService)
await invoke('minimaxCredentials:saveCookie', '_token=abc')
await new Promise((resolve) => setImmediate(resolve))
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('failed to trigger rate-limit refresh after save'),
expect.any(Error)
)
})
})
+52
View File
@@ -0,0 +1,52 @@
import { ipcMain } from 'electron'
import {
clearMiniMaxSessionCookie,
hasMiniMaxSessionCookie,
saveMiniMaxSessionCookie
} from '../minimax/minimax-cookie-store'
import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context'
import type { RateLimitService } from '../rate-limits/service'
export type MiniMaxCredentialsStatus = {
configured: boolean
}
function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus {
return { configured: hasMiniMaxSessionCookie() }
}
// Why: fire-and-forget — callers get the persisted cookie status immediately;
// the rate-limit refresh runs in the background and only logs on failure.
function refreshAfterMiniMaxCredentialChange(
rateLimits: RateLimitService | null,
action: 'save' | 'clear'
): void {
rateLimits?.invalidateMiniMaxCredentialState()
void rateLimits?.refresh().catch((error: unknown) => {
console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error)
})
}
export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void {
ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus())
ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => {
// Validate the IPC argument in the main process; the renderer-declared type
// is compile-time only and the value arrives as unknown over IPC.
if (typeof cookie !== 'string') {
throw new Error('MiniMax session cookie must be a string')
}
saveMiniMaxSessionCookie(cookie)
refreshAfterMiniMaxCredentialChange(rateLimits, 'save')
return getMiniMaxCredentialsStatus()
})
ipcMain.handle('minimaxCredentials:clearCookie', async () => {
clearMiniMaxSessionCookie()
try {
await clearMiniMaxSessionCookieJar()
} catch (error) {
console.error('[minimax] failed to clear session cookie jar after credential clear:', error)
}
refreshAfterMiniMaxCredentialChange(rateLimits, 'clear')
return getMiniMaxCredentialsStatus()
})
}
+11
View File
@@ -3509,6 +3509,17 @@ export function registerPtyHandlers(
.catch(() => {})
})
ipcMain.removeAllListeners('pty:clearBuffer')
ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => {
// Why: the renderer already cleared its own xterm buffer. This clears the
// PTY-side state (ConPTY screen buffer, daemon emulator, SSH host buffer)
// so the next prompt repaint doesn't land at a stale cursor row.
tryGetProviderForPty(args.id)
?.clearBuffer(args.id)
.catch(() => {})
runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {})
})
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
const ownedConnectionId = ptyOwnership.get(args.id)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
+57
View File
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const ipcState = vi.hoisted(() => ({
handleHandlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
ipcState.handleHandlers.set(channel, handler)
}
}
}))
import { registerRateLimitHandlers } from './rate-limits'
import type { RateLimitService } from '../rate-limits/service'
import type { RateLimitState } from '../../shared/rate-limit-types'
function makeService(): { service: RateLimitService; refresh: ReturnType<typeof vi.fn> } {
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
const service = {
getState: vi.fn(() => ({}) as RateLimitState),
refresh,
refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
consumeCodexRateLimitResetCredit: vi.fn(() =>
Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
),
setPollingInterval: vi.fn(() => Promise.resolve()),
fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()),
fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve())
}
return { service: service as unknown as RateLimitService, refresh }
}
describe('registerRateLimitHandlers', () => {
beforeEach(() => {
ipcState.handleHandlers.clear()
})
it('registers a refreshMiniMax channel that delegates to refresh()', async () => {
const { service, refresh } = makeService()
registerRateLimitHandlers(service)
const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax')
expect(handler).toBeDefined()
await handler!({})
expect(refresh).toHaveBeenCalledTimes(1)
})
it('keeps the existing rate-limit channels registered', () => {
const { service } = makeService()
registerRateLimitHandlers(service)
expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true)
expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
})
})
+1
View File
@@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () =>
rateLimits.fetchInactiveCodexAccountsOnOpen()
)
ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
}
@@ -34,6 +34,7 @@ const {
registerAgentHookHandlersMock,
registerAgentTrustHandlersMock,
registerClaudeAccountHandlersMock,
registerMiniMaxCredentialsHandlersMock,
registerClipboardHandlersMock,
setTrustedClipboardRendererWebContentsIdMock,
registerUpdaterHandlersMock,
@@ -89,6 +90,7 @@ const {
registerAgentHookHandlersMock: vi.fn(),
registerAgentTrustHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
registerMiniMaxCredentialsHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn(),
@@ -279,6 +281,10 @@ vi.mock('./claude-accounts', () => ({
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
}))
vi.mock('./minimax-credentials', () => ({
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
}))
vi.mock('../window/attach-main-window-services', () => ({
registerUpdaterHandlers: registerUpdaterHandlersMock
}))
@@ -353,6 +359,7 @@ describe('registerCoreHandlers', () => {
registerAgentHookHandlersMock.mockReset()
registerAgentTrustHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
registerMiniMaxCredentialsHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
setTrustedClipboardRendererWebContentsIdMock.mockReset()
registerUpdaterHandlersMock.mockReset()
@@ -418,6 +425,7 @@ describe('registerCoreHandlers', () => {
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
expect(registerLinearHandlersMock).toHaveBeenCalled()
+2
View File
@@ -53,6 +53,7 @@ import { registerCodexAccountHandlers } from './codex-accounts'
import { registerAgentHookHandlers } from './agent-hooks'
import { registerAgentTrustHandlers } from './agent-trust'
import { registerClaudeAccountHandlers } from './claude-accounts'
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
import {
registerClipboardHandlers,
@@ -117,6 +118,7 @@ export function registerCoreHandlers(
registerAgentHookHandlers(runtime)
registerAgentTrustHandlers()
registerClaudeAccountHandlers(claudeAccounts)
registerMiniMaxCredentialsHandlers(rateLimits)
registerRateLimitHandlers(rateLimits)
registerGitHubHandlers(store, stats)
registerGitLabHandlers(store)
+25 -10
View File
@@ -2413,13 +2413,13 @@ describe('repos:getBaseRefDefault envelope', () => {
// independent of which Promise in the Promise.all resolves first.
type ExecResponse = { stdout: string; stderr: string }
type ExecRule = {
match: (argv: string[]) => boolean
matches: (argv: string[]) => boolean
respond: () => Promise<ExecResponse>
}
const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise<ExecResponse>) => {
return (argv: string[]) => {
for (const rule of rules) {
if (rule.match(argv)) {
if (rule.matches(argv)) {
return rule.respond()
}
}
@@ -2438,11 +2438,17 @@ describe('repos:getBaseRefDefault envelope', () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
{
match: isSymbolicRef,
matches: isSymbolicRef,
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
},
// The origin/HEAD target is verified before it is trusted, so the
// symbolic-ref result must also resolve via rev-parse.
{
match: isRemoteList,
matches: isRevParseFor('refs/remotes/origin/main'),
respond: () => Promise.resolve({ stdout: '', stderr: '' })
},
{
matches: isRemoteList,
respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
}
])
@@ -2468,11 +2474,17 @@ describe('repos:getBaseRefDefault envelope', () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
{
match: isSymbolicRef,
matches: isSymbolicRef,
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
},
// The origin/HEAD target is verified before it is trusted, so the
// symbolic-ref result must also resolve via rev-parse.
{
match: isRemoteList,
matches: isRevParseFor('refs/remotes/origin/main'),
respond: () => Promise.resolve({ stdout: '', stderr: '' })
},
{
matches: isRemoteList,
respond: () => Promise.reject(new Error('relay exec failed'))
}
])
@@ -2500,18 +2512,21 @@ describe('repos:getBaseRefDefault envelope', () => {
mockGitProvider.exec = vi.fn().mockImplementation(
dispatchExec([
// symbolic-ref rejects (no origin/HEAD on the remote)
{ match: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) },
{ matches: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) },
// probe 1: refs/remotes/origin/main — rejects
{
match: isRevParseFor('refs/remotes/origin/main'),
matches: isRevParseFor('refs/remotes/origin/main'),
respond: () => Promise.reject(new Error('missing'))
},
// probe 2: refs/remotes/origin/master — succeeds
{
match: isRevParseFor('refs/remotes/origin/master'),
matches: isRevParseFor('refs/remotes/origin/master'),
respond: () => Promise.resolve({ stdout: 'abc123\n', stderr: '' })
},
{ match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) }
{
matches: isRemoteList,
respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' })
}
])
)
@@ -0,0 +1,50 @@
import { getActiveMultiplexer } from './ssh'
const SSH_CONNECTION_UNAVAILABLE_MESSAGE =
'SSH connection is not available. Please reconnect and try again.'
type SshRootRegistrationMux = {
request: (method: 'session.registerRoot', payload: { rootPath: string }) => Promise<unknown>
notify: (method: 'session.registerRoot', payload: { rootPath: string }) => void
}
async function registerSshWorktreeCreateRoots(
mux: SshRootRegistrationMux,
rootPaths: string[]
): Promise<void> {
try {
await Promise.all(
rootPaths.map((rootPath) => mux.request('session.registerRoot', { rootPath }))
)
} catch (err) {
if (err instanceof Error && err.message.includes('Method not found')) {
for (const rootPath of rootPaths) {
mux.notify('session.registerRoot', { rootPath })
}
return
}
throw err
}
}
export async function registerOptionalSshWorktreeCreateRoots(
connectionId: string,
rootPaths: string[]
): Promise<void> {
const mux = getActiveMultiplexer(connectionId)
if (!mux) {
return
}
await registerSshWorktreeCreateRoots(mux, rootPaths)
}
export async function registerRequiredSshWorktreeCreateRoots(
connectionId: string,
rootPaths: string[]
): Promise<void> {
const mux = getActiveMultiplexer(connectionId)
if (!mux) {
throw new Error(SSH_CONNECTION_UNAVAILABLE_MESSAGE)
}
await registerSshWorktreeCreateRoots(mux, rootPaths)
}
+18 -1
View File
@@ -5,7 +5,7 @@ import type { Store } from '../persistence'
import { SshConnectionStore } from '../ssh/ssh-connection-store'
import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { SshRelaySession } from '../ssh/ssh-relay-session'
import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session'
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
import type {
DetectedPort,
@@ -123,6 +123,23 @@ export async function removeRegisteredSshTarget(targetId: string): Promise<void>
// scattered Maps/Sets that previously tracked this state independently.
const activeSessions = new Map<string, SshRelaySession>()
export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null {
if (isRuntimeOwnedSshTargetId(targetId)) {
return null
}
return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null
}
export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] {
return [...activeSessions.values()].flatMap((session) => {
if (isRuntimeOwnedSshTargetId(session.targetId)) {
return []
}
const info = session.getAiVaultHostInfo()
return info ? [info] : []
})
}
async function detachActiveSshSession(targetId: string): Promise<void> {
await teardownActiveSshSession(targetId, (session) => session.detach())
}
+158 -67
View File
@@ -30,6 +30,8 @@ import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
import { resolveLocalGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
import type { ForgeProviderId } from '../source-control/forge-provider'
import { validateGitPushTarget } from '../git/push-target-validation'
@@ -53,12 +55,15 @@ import {
} from '../hooks'
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getActiveMultiplexer } from './ssh'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { getSshGitUsername } from '../git/git-username'
import { runWorktreeChangeInvalidators } from './worktree-change-invalidators'
import {
registerOptionalSshWorktreeCreateRoots,
registerRequiredSshWorktreeCreateRoots
} from './ssh-worktree-create-root-registration'
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
automationProvenance?: AutomationWorkspaceProvenance
@@ -608,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,
@@ -626,6 +660,23 @@ 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
@@ -1218,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
}
@@ -1280,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
@@ -1471,6 +1528,10 @@ export async function createRemoteWorktree(
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Why: resolving the create base can probe repo refs through generic git.exec.
// Register the repo root first so relays do not report a valid base as stale.
await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path])
// Why: SSH targets cannot use the local `gh` account, and git email/name are
// commit author identity rather than hosted-account usernames.
const username = await getSshGitUsername(provider, repo.path)
@@ -1488,7 +1549,8 @@ export async function createRemoteWorktree(
'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.'
)
}
const { baseBranch, remoteTrackingBase } = basePlan
const { baseBranch } = basePlan
let { remoteTrackingBase } = basePlan
let branchName = ''
let checkoutExistingBranch = false
@@ -1594,25 +1656,20 @@ export async function createRemoteWorktree(
}
}
const mux = getActiveMultiplexer(repo.connectionId!)
if (!mux) {
throw new Error('SSH connection is not available. Please reconnect and try again.')
}
// Why: register before any generic git.exec probe (base-ref existence, the
// local-base advisory) as well as addWorktree. Fresh/older relays may gate
// generic git.exec on registered roots; probing first would falsely report a
// ref missing (and defeat the offline fallback below) even though create works.
try {
await Promise.all([
mux.request('session.registerRoot', { rootPath: repo.path }),
mux.request('session.registerRoot', { rootPath: remotePath })
])
} catch (err) {
if (err instanceof Error && err.message.includes('Method not found')) {
mux.notify('session.registerRoot', { rootPath: repo.path })
mux.notify('session.registerRoot', { rootPath: remotePath })
} else {
throw err
// Why: addWorktree and setup probes run inside the new worktree path; older
// relays need that root registered before accepting git/fs operations there.
await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath])
if (remoteTrackingBase) {
const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh(
provider,
repo.path,
remoteTrackingBase.ref
)
const hasLocalBaseRef =
hasRemoteTrackingBaseRef || (await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))
if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) {
remoteTrackingBase = null
}
}
@@ -1633,7 +1690,7 @@ export async function createRemoteWorktree(
)
}
}
} else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) {
} else if (!(await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))) {
// Why: local or otherwise non-remote-tracking bases preserve legacy
// best-effort fetch behavior. Verified PR/MR SHA bases already have the
// commit object locally, so a broad remote fetch only updates unrelated refs.
@@ -1914,15 +1971,40 @@ export async function createLocalWorktree(
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Why: resolve the base before branch/path selection so remote-tracking bases
// can be refreshed before `git worktree add`. Creating first and repairing
// later races setup scripts, agents, and user edits.
const baseBranch =
args.baseBranch ||
repo.worktreeBaseRef ||
(await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)))
const baseBranch = await resolveWorktreeCreateBase({
requestedBaseBranch: args.baseBranch,
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: () =>
resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)),
isBaseUsable: async (baseBranchCandidate) => {
if (runtime) {
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
repo.path,
baseBranchCandidate,
...localWorktreeGitOptionArgs
)
if (remoteTrackingBase) {
if (
await runtime.hasRemoteTrackingRef(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
) {
return true
}
return hasLocalWorktreeBaseRefWithOptions(
repo.path,
baseBranchCandidate,
localGitExecOptions
)
}
}
return hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranchCandidate, localGitExecOptions)
}
})
if (!baseBranch) {
// Why: getDefaultBaseRef may return null when none of origin/HEAD,
// Why: resolveDefaultBaseRefViaExec may return null when none of origin/HEAD,
// origin/main, origin/master, local main, or local master exist. Don't
// fall back to a hardcoded 'origin/main' — passing a non-existent ref to
// `git worktree add` produces an opaque error. Fail here with a clear
@@ -1947,23 +2029,30 @@ export async function createLocalWorktree(
...localWorktreeGitOptionArgs
)
if (remoteTrackingBase) {
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(
const hasRemoteTrackingBaseRef = await runtime.hasRemoteTrackingRef(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
remoteTrackingRefresh = {
base: remoteTrackingBase,
hadLocalBaseRef: hasLocalBaseRef,
promise: runtime.getOrStartRemoteTrackingBaseRefresh(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
const hasLocalBaseRef =
hasRemoteTrackingBaseRef ||
(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions))
if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) {
remoteTrackingBase = null
} else {
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
remoteTrackingRefresh = {
base: remoteTrackingBase,
hadLocalBaseRef: hasRemoteTrackingBaseRef,
promise: runtime.getOrStartRemoteTrackingBaseRefresh(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
}
}
} else if (
!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
) {
// Why: when the base branch does not match a configured remote prefix
// (e.g. plain `main`, `master`, or any local branch), the legacy path
@@ -1976,7 +2065,9 @@ export async function createLocalWorktree(
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
} else {
if (!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))) {
if (
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
) {
legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], {
...localGitExecOptions,
timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS
+538 -45
View File
@@ -493,16 +493,32 @@ describe('registerWorktreeHandlers', () => {
})
it('prefetches the local default create base through the runtime refresh cache', async () => {
const repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: 'origin/master'
}
const remoteBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
store.getRepo.mockReturnValue(repo)
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
baseBranch === 'origin/main' ? remoteBase : null
)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
'/workspace/repo',
'origin/master'
)
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
'/workspace/repo',
'origin/main'
@@ -3668,7 +3684,7 @@ describe('registerWorktreeHandlers', () => {
})
})
it('does not create an SSH worktree when the refresh fails and no local base ref exists', async () => {
it('keeps an explicit SSH base strict when refresh fails and no local base ref exists', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -3679,7 +3695,6 @@ describe('registerWorktreeHandlers', () => {
worktreeBaseRef: 'origin/main'
}
const provider = {
// Empty rev-parse stdout -> no local remote-tracking base ref to fall back on.
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
@@ -3705,24 +3720,26 @@ describe('registerWorktreeHandlers', () => {
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
name: 'improve-dashboard',
baseBranch: 'origin/master'
})
).rejects.toThrow(
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
'Could not refresh base ref "origin/master" from "origin". Check your network and try again.'
)
expect(provider.addWorktree).not.toHaveBeenCalled()
expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled()
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'origin',
'main',
'refs/remotes/origin/main'
'master',
'refs/remotes/origin/master'
)
})
it('creates an SSH worktree from an existing local base ref when the refresh fails', async () => {
// Regression: a failed SSH refresh must fall back to an existing local
// remote-tracking base ref instead of blocking creation.
it('creates an SSH worktree from the detected default base when the persisted base is stale', async () => {
// Regression: a stale persisted repo base must fall back to the detected
// primary default instead of blocking creation.
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -3730,20 +3747,25 @@ describe('registerWorktreeHandlers', () => {
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
worktreeBaseRef: 'origin/master'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
// Local remote-tracking base ref resolves -> usable fallback exists.
if (args[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
return { stdout: '', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
return { stdout: `${'a'.repeat(40)}\n`, stderr: '' }
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi
.fn()
@@ -3773,15 +3795,175 @@ describe('registerWorktreeHandlers', () => {
name: 'improve-dashboard'
})
// The refresh must be ATTEMPTED (and fail) before falling back — guards
// against a regression that skips the refresh whenever a local ref exists.
expect(resolveDefaultBaseRefViaExecMock).toHaveBeenCalled()
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'origin',
'main',
'refs/remotes/origin/main'
)
expect(provider.addWorktree).toHaveBeenCalled()
expect(provider.addWorktree).toHaveBeenCalledWith(
'/remote/repo',
'improve-dashboard',
'/remote/improve-dashboard',
{
base: 'origin/main'
}
)
})
it('keeps a usable SSH persisted local branch base after registering the repo root', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'develop'
}
let repoRootRegistered = false
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
return { stdout: repoRootRegistered ? 'develop-sha\n' : '', stderr: '' }
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/local-branch-base',
head: 'develop-sha',
branch: 'refs/heads/local-branch-base',
isBare: false,
isMainWorktree: false
}
])
}
const mux = {
request: vi
.fn()
.mockImplementation(async (_method: string, payload: { rootPath: string }) => {
if (payload.rootPath === repo.path) {
repoRootRegistered = true
}
}),
notify: vi.fn()
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'local-branch-base'
})
expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { rootPath: repo.path })
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled()
expect(provider.addWorktree).toHaveBeenCalledWith(
'/remote/repo',
'local-branch-base',
'/remote/local-branch-base',
{
base: 'develop'
}
)
})
it('keeps a usable SSH slash-named local branch base that matches a remote prefix', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'team/feature'
}
let repoRootRegistered = false
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'remote') {
return { stdout: 'team\norigin\n', stderr: '' }
}
if (args[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
return { stdout: '', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
return { stdout: repoRootRegistered ? 'team-feature-sha\n' : '', stderr: '' }
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/slash-local-base',
head: 'team-feature-sha',
branch: 'refs/heads/slash-local-base',
isBare: false,
isMainWorktree: false
}
])
}
const mux = {
request: vi
.fn()
.mockImplementation(async (_method: string, payload: { rootPath: string }) => {
if (payload.rootPath === repo.path) {
repoRootRegistered = true
}
}),
notify: vi.fn()
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'slash-local-base'
})
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
'/remote/repo',
'team',
'feature',
'refs/remotes/team/feature'
)
expect(provider.addWorktree).toHaveBeenCalledWith(
'/remote/repo',
'slash-local-base',
'/remote/slash-local-base',
{
base: 'team/feature'
}
)
})
it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => {
@@ -3986,6 +4168,160 @@ describe('registerWorktreeHandlers', () => {
expect(provider.addWorktree).toHaveBeenCalledTimes(1)
})
it('registers the SSH repo root before create-base prefetch probes refs', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/master'
}
const registeredRoots = new Set<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',
@@ -4241,7 +4577,16 @@ describe('registerWorktreeHandlers', () => {
isMainWorktree: false
}
])
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (
args[0] === 'rev-parse' &&
(args.includes('refs/remotes/origin/master^{commit}') ||
args.includes('refs/heads/origin/master^{commit}'))
) {
throw new Error('missing ref')
}
return { stdout: 'created-sha\n', stderr: '' }
})
const createPromise = handlers['worktrees:create'](null, {
repoId: 'repo-1',
@@ -4270,17 +4615,82 @@ describe('registerWorktreeHandlers', () => {
)
})
it('creates from an existing local base ref when the pre-create refresh fails', async () => {
// Regression: a failed refresh must not block creation when a usable local
// remote-tracking base ref already exists.
it('creates from the detected default base when the persisted base is stale', async () => {
// Regression: a stale persisted repo base must fall back to the detected
// primary default instead of blocking creation.
const remoteBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: 'origin/master'
})
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
baseBranch === 'origin/main' ? remoteBase : null
)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
ok: true,
errorKind: 'git_error'
})
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
head: 'created-sha',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (
args[0] === 'rev-parse' &&
(args.includes('refs/remotes/origin/master^{commit}') ||
args.includes('refs/heads/origin/master^{commit}'))
) {
throw new Error('missing ref')
}
return { stdout: 'created-sha\n', stderr: '' }
})
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})) as CreateWorktreeResult
expect(addWorktreeMock).toHaveBeenCalled()
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
'/workspace/repo',
'origin/master'
)
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
'/workspace/repo',
'origin/main'
)
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
'/workspace/repo',
remoteBase
)
expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
})
it('keeps a usable persisted local branch base when a detected default exists', async () => {
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: 'develop'
})
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
ok: false,
errorKind: 'git_error'
@@ -4294,29 +4704,14 @@ describe('registerWorktreeHandlers', () => {
isMainWorktree: false
}
])
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})) as CreateWorktreeResult
expect(addWorktreeMock).toHaveBeenCalled()
expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
})
it('does not create when the pre-create refresh fails and no local base ref exists', async () => {
const remoteBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
ok: false,
errorKind: 'git_error'
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
return { stdout: 'develop-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
throw new Error('network unavailable')
}
return { stdout: 'created-sha\n', stderr: '' }
})
await expect(
@@ -4324,11 +4719,109 @@ describe('registerWorktreeHandlers', () => {
repoId: 'repo-1',
name: 'improve-dashboard'
})
).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) }))
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/improve-dashboard',
'improve-dashboard',
'develop',
false
)
})
it('keeps a usable persisted slash-named local branch base that matches a remote prefix', async () => {
const remoteBase = {
remote: 'team',
branch: 'feature',
ref: 'refs/remotes/team/feature',
base: 'team/feature'
}
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: 'team/feature'
})
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
baseBranch === 'team/feature' ? remoteBase : null
)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/slash-local-base',
head: 'created-sha',
branch: 'slash-local-base',
isBare: false,
isMainWorktree: false
}
])
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
throw new Error('missing remote-tracking ref')
}
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
return { stdout: 'team-feature-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
throw new Error('network unavailable')
}
return { stdout: 'created-sha\n', stderr: '' }
})
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'slash-local-base'
})
).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) }))
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled()
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/slash-local-base',
'slash-local-base',
'team/feature',
false
)
})
it('keeps an explicit base strict when the pre-create refresh fails', async () => {
const remoteBase = {
remote: 'origin',
branch: 'master',
ref: 'refs/remotes/origin/master',
base: 'origin/master'
}
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
ok: false,
errorKind: 'git_error'
})
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: 'origin/main'
})
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
baseBranch: 'origin/master'
})
).rejects.toThrow(
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
'Could not refresh base ref "origin/master" from "origin". Check your network and try again.'
)
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled()
})
it('delegates remote-tracking base freshness to the runtime before create', async () => {
+31 -8
View File
@@ -139,7 +139,7 @@ describe('registerAppMenu', () => {
expect(options.onBeforeReload).toHaveBeenCalledWith({ ignoreCache: true, webContentsId: 102 })
})
it('includes prereleases when Check for Updates is clicked with shift held', () => {
it('routes Check for Updates modifier clicks to prerelease and perf checks', () => {
const options = buildMenuOptions()
registerAppMenu(options)
@@ -152,19 +152,42 @@ describe('registerAppMenu', () => {
)
item?.click?.({} as never, undefined as never, { shiftKey: true } as Electron.KeyboardEvent)
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
item?.click?.(
{} as never,
undefined as never,
{ metaKey: true, shiftKey: true } as Electron.KeyboardEvent
{
shiftKey: true,
...(isMac ? { metaKey: true } : { ctrlKey: true })
} as Electron.KeyboardEvent
)
item?.click?.(
{} as never,
undefined as never,
(isMac ? { metaKey: true } : { ctrlKey: true }) as Electron.KeyboardEvent
)
item?.click?.(
{} as never,
undefined as never,
(isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent
)
item?.click?.(
{} as never,
undefined as never,
{
triggeredByAccelerator: true,
shiftKey: true,
...(isMac ? { metaKey: true } : { ctrlKey: true })
} as Electron.KeyboardEvent
)
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent)
expect(options.onCheckForUpdates.mock.calls).toEqual([
[{ includePrerelease: true }],
[{ includePrerelease: true }],
[{ includePrerelease: false }],
[{ includePrerelease: false }]
[{ includePrerelease: true, includePerfPrerelease: false }],
[{ includePrerelease: false, includePerfPrerelease: false }],
[{ includePrerelease: true, includePerfPrerelease: true }],
[{ includePrerelease: false, includePerfPrerelease: true }],
[{ includePrerelease: false, includePerfPrerelease: false }],
[{ includePrerelease: false, includePerfPrerelease: false }]
])
})
+10 -6
View File
@@ -5,6 +5,7 @@ import {
type KeybindingActionId,
type KeybindingOverrides
} from '../../shared/keybindings'
import type { UpdateCheckOptions } from '../../shared/types'
import { translateMain } from '../i18n/main-i18n'
export type AppearanceMenuState = {
@@ -26,7 +27,7 @@ type RegisterAppMenuOptions = {
onOpenSetupGuide: (window?: Electron.BaseWindow | null) => void
onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void
onOpenCrashReport: (window?: Electron.BaseWindow | null) => void
onCheckForUpdates: (options: { includePrerelease: boolean }) => void
onCheckForUpdates: (options: UpdateCheckOptions) => void
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
onZoomIn: () => void
onZoomOut: () => void
@@ -83,16 +84,19 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
webContents.reload()
}
// Why: holding Shift while clicking Check for Updates opts this check into
// the release-candidate channel. Extracted so both the macOS app-menu entry
// and the Windows/Linux Help-menu entry share the exact same behavior.
// Why: modifier-click update checks are hidden power-user affordances.
// Extracted so the macOS app-menu entry and Windows/Linux Help entry share
// identical RC/perf channel routing.
const checkForUpdatesClick: Electron.MenuItemConstructorOptions['click'] = (
_menuItem,
_window,
event
) => {
const includePrerelease = !event.triggeredByAccelerator && event.shiftKey === true
onCheckForUpdates({ includePrerelease })
const modifierClick = !event.triggeredByAccelerator
const includePerfPrerelease =
modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true)
const includePrerelease = modifierClick && event.shiftKey === true
onCheckForUpdates({ includePrerelease, includePerfPrerelease })
}
const checkForUpdatesItem: Electron.MenuItemConstructorOptions = {
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MiniMaxCookieStore from './minimax-cookie-store'
const safeStorageMock = vi.hoisted(() => ({
isEncryptionAvailable: vi.fn(() => true),
encryptString: vi.fn((value: string) => Buffer.from(value)),
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
}))
const electronMock = vi.hoisted(() => ({
safeStorage: safeStorageMock
}))
vi.mock('electron', () => electronMock)
const existsSyncMock = vi.fn()
const readFileSyncMock = vi.fn()
const rmSyncMock = vi.fn()
const hardenExistingSecureFileMock = vi.fn()
const writeSecureFileMock = vi.fn()
const homedirMock = vi.fn(() => '/home/test')
vi.mock('node:fs', () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
rmSync: rmSyncMock
}))
vi.mock('node:os', () => ({
homedir: homedirMock
}))
vi.mock('node:path', () => ({
join: (...parts: string[]) => parts.join('/')
}))
vi.mock('../../shared/secure-file', () => ({
hardenExistingSecureFile: hardenExistingSecureFileMock,
writeSecureFile: writeSecureFileMock
}))
const storePath = '/home/test/.orca/minimax-session-cookie.enc'
const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
`orca-minimax-cookie:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
async function loadStore(): Promise<typeof MiniMaxCookieStore> {
return await import('./minimax-cookie-store')
}
describe('minimax-cookie-store', () => {
beforeEach(() => {
existsSyncMock.mockReset()
readFileSyncMock.mockReset()
rmSyncMock.mockReset()
hardenExistingSecureFileMock.mockReset()
writeSecureFileMock.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
})
afterEach(() => {
vi.resetModules()
})
it('returns false when no file exists yet', async () => {
existsSyncMock.mockReturnValue(false)
const store = await loadStore()
expect(store.hasMiniMaxSessionCookie()).toBe(false)
expect(hardenExistingSecureFileMock).not.toHaveBeenCalled()
})
it('hardens the cookie file when checking status for an existing cookie', async () => {
existsSyncMock.mockReturnValue(true)
const store = await loadStore()
expect(store.hasMiniMaxSessionCookie()).toBe(true)
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
})
it('still reports an existing cookie when status-path hardening fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
existsSyncMock.mockReturnValue(true)
hardenExistingSecureFileMock.mockImplementation(() => {
throw new Error('permission denied')
})
const store = await loadStore()
expect(store.hasMiniMaxSessionCookie()).toBe(true)
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to harden MiniMax cookie file'),
expect.any(Error)
)
warn.mockRestore()
})
it('writes the cookie using safeStorage when encryption is available', async () => {
existsSyncMock.mockReturnValue(false)
const store = await loadStore()
store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42')
expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
expect(writeSecureFileMock).toHaveBeenCalledWith(
storePath,
envelope('encrypted', '_token=abc; minimax_group_id_v2=42')
)
})
it('warns and writes plaintext when safeStorage is unavailable', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(false)
const store = await loadStore()
store.saveMiniMaxSessionCookie('_token=abc')
expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', '_token=abc'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
warn.mockRestore()
})
it('refuses empty cookies', async () => {
const store = await loadStore()
expect(() => store.saveMiniMaxSessionCookie(' ')).toThrow(/required/)
})
it('reads decrypted cookie from disk and caches it', async () => {
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9')
const store = await loadStore()
const first = store.readMiniMaxSessionCookie()
const second = store.readMiniMaxSessionCookie()
expect(first).toBe('_token=cached; minimax_group_id_v2=9')
expect(second).toBe(first)
expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1)
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1)
expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload'))
})
it('returns null when no file exists', async () => {
existsSyncMock.mockReturnValue(false)
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBeNull()
})
it('returns enveloped plaintext when safeStorage is unavailable and reads succeed', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', '_token=plaintext')))
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext')
})
it('reads legacy plaintext cookies when decrypting is unavailable', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
})
it('reads legacy plaintext cookies when decrypting fails', async () => {
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
safeStorageMock.decryptString.mockImplementation(() => {
throw new Error('boom')
})
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
})
it('does not treat encrypted legacy bytes as plaintext when safeStorage is unavailable', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload'))
const store = await loadStore()
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
})
it('throws for encrypted envelopes when safeStorage is unavailable', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
const store = await loadStore()
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
})
it('throws when decryption fails', async () => {
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
safeStorageMock.decryptString.mockImplementation(() => {
throw new Error('boom')
})
const store = await loadStore()
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
})
it('clears the cached cookie and removes the file', async () => {
existsSyncMock.mockReturnValueOnce(true)
readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear')
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear')
store.clearMiniMaxSessionCookie()
expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
expect(store.readMiniMaxSessionCookie()).toBeNull()
})
})
+165
View File
@@ -0,0 +1,165 @@
import { safeStorage } from 'electron'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc'
const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:'
let cachedMiniMaxCookie: string | null = null
let warnedMiniMaxCookieStatusHardenFailure = false
type MiniMaxCookieEnvelope = {
kind: 'encrypted' | 'plaintext'
payload: Buffer
}
function getOrcaDir(): string {
return join(homedir(), '.orca')
}
function getMiniMaxCookiePath(): string {
return join(getOrcaDir(), MINIMAX_COOKIE_FILE)
}
function encodeCookieEnvelope(kind: MiniMaxCookieEnvelope['kind'], payload: Buffer): string {
return `${COOKIE_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}`
}
function decodeCookieEnvelope(raw: Buffer): MiniMaxCookieEnvelope | null {
const text = raw.toString('utf8')
if (!text.startsWith(COOKIE_ENVELOPE_PREFIX)) {
return null
}
const rest = text.slice(COOKIE_ENVELOPE_PREFIX.length)
const separator = rest.indexOf(':')
if (separator < 0) {
throw new Error('MiniMax session cookie could not be decrypted')
}
const kind = rest.slice(0, separator)
if (kind !== 'encrypted' && kind !== 'plaintext') {
throw new Error('MiniMax session cookie could not be decrypted')
}
return {
kind,
payload: Buffer.from(rest.slice(separator + 1), 'base64')
}
}
// Why: migrates cookies saved before the envelope format existed. Older files
// hold raw bytes (safeStorage-encrypted or plaintext), so we sniff the content
// to tell the two apart rather than removing this as seemingly dead code.
function looksLikeCookieHeader(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) {
return false
}
for (let index = 0; index < trimmed.length; index += 1) {
const code = trimmed.charCodeAt(index)
if (code < 32 || code === 127) {
return false
}
}
return (
/^Cookie:\s*\S+/i.test(trimmed) ||
/(?:^|;\s*)[A-Za-z0-9_.-]+\s*=/.test(trimmed) ||
/(?:^|[;\s])[A-Za-z0-9_.-]+\s*:\s*["'][^"']+["']/.test(trimmed)
)
}
function readEnvelope(envelope: MiniMaxCookieEnvelope): string {
if (envelope.kind === 'plaintext') {
return envelope.payload.toString('utf8')
}
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('MiniMax session cookie could not be decrypted')
}
return safeStorage.decryptString(envelope.payload)
}
function readLegacyCookie(raw: Buffer): string {
if (safeStorage.isEncryptionAvailable()) {
try {
return safeStorage.decryptString(raw)
} catch {
const plaintext = raw.toString('utf8')
if (looksLikeCookieHeader(plaintext)) {
return plaintext
}
throw new Error('MiniMax session cookie could not be decrypted')
}
}
const plaintext = raw.toString('utf8')
if (looksLikeCookieHeader(plaintext)) {
return plaintext
}
throw new Error('MiniMax session cookie could not be decrypted')
}
export function hasMiniMaxSessionCookie(): boolean {
const keyPath = getMiniMaxCookiePath()
if (!existsSync(keyPath)) {
return false
}
try {
hardenExistingSecureFile(keyPath)
} catch (error) {
if (!warnedMiniMaxCookieStatusHardenFailure) {
warnedMiniMaxCookieStatusHardenFailure = true
console.warn('[minimax] Failed to harden MiniMax cookie file while checking status', error)
}
}
return true
}
export function saveMiniMaxSessionCookie(cookie: string): void {
const trimmed = cookie.trim()
if (!trimmed) {
throw new Error('MiniMax session cookie is required')
}
if (safeStorage.isEncryptionAvailable()) {
writeSecureFile(
getMiniMaxCookiePath(),
encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed))
)
cachedMiniMaxCookie = trimmed
return
}
console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext')
writeSecureFile(
getMiniMaxCookiePath(),
encodeCookieEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
)
cachedMiniMaxCookie = trimmed
}
export function readMiniMaxSessionCookie(): string | null {
if (cachedMiniMaxCookie !== null) {
return cachedMiniMaxCookie
}
const keyPath = getMiniMaxCookiePath()
if (!existsSync(keyPath)) {
return null
}
// Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
// failure isn't misreported as a decrypt failure (matches hasMiniMaxSessionCookie).
try {
hardenExistingSecureFile(keyPath)
} catch (error) {
console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error)
}
try {
const raw = readFileSync(keyPath)
const envelope = decodeCookieEnvelope(raw)
cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw)
return cachedMiniMaxCookie
} catch (error) {
console.error('[minimax] failed to decode/decrypt session cookie', error)
throw new Error('MiniMax session cookie could not be decrypted')
}
}
export function clearMiniMaxSessionCookie(): void {
cachedMiniMaxCookie = null
rmSync(getMiniMaxCookiePath(), { force: true })
}
@@ -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', () => {
@@ -218,6 +219,52 @@ describe('LocalPtyProvider', () => {
)
})
it('allows an explicitly requested plain shell at POSIX root', async () => {
await provider.spawn({ cols: 80, rows: 24, cwd: '/' })
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ cwd: '/' })
)
})
it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => {
spawnMock.mockClear()
const origHome = process.env.HOME
// Pin HOME so we assert the exact resolved candidate, not just non-root-ness —
// catches regressions where resolveSafePtyDefaultCwd picks an unintended home.
process.env.HOME = '/home/testuser'
try {
// Why: an omitted cwd resolves to a guaranteed-safe default home (the
// guard only rejects root-like paths), so the agent must still launch.
await expect(
provider.spawn({ cols: 80, rows: 24, command: 'codex' })
).resolves.toBeDefined()
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].cwd).toBe('/home/testuser')
expect(isRootLikePath(spawnCall[2].cwd)).toBe(false)
} finally {
if (origHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = origHome
}
}
})
it('rejects automatic agent startup at POSIX root', async () => {
spawnMock.mockClear()
await expect(
provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' })
).rejects.toThrow(/requires a non-root workspace/)
expect(spawnMock).not.toHaveBeenCalled()
})
it('invokes onSpawned callback', async () => {
const onSpawned = vi.fn()
provider.configure({ onSpawned })
+26 -18
View File
@@ -55,6 +55,7 @@ import { resolveAgentForegroundProcess } from './agent-foreground-process'
import { getAgentForegroundContextPaths } from './agent-foreground-context-paths'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -88,20 +89,7 @@ const exitListeners = new Set<ExitCallback>()
* Returns a stable default cwd for locally spawned PTYs.
*/
function getDefaultCwd(): string {
if (process.platform !== 'win32') {
return process.env.HOME || '/'
}
// Why: USERPROFILE is not guaranteed in all Windows launch contexts.
// Falling back to bare HOMEPATH yields a drive-relative path, so combine
// HOMEDRIVE + HOMEPATH to keep spawned PTYs anchored to the intended home.
if (process.env.USERPROFILE) {
return process.env.USERPROFILE
}
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
}
return 'C:\\'
return resolveSafePtyDefaultCwd()
}
/**
@@ -352,8 +340,18 @@ export class LocalPtyProvider implements IPtyProvider {
}
const id = allocatePtyId(reattachId ?? undefined)
const startupAgentRecognition = args.command
? recognizeAgentProcessFromCommandLine(args.command)
: null
const defaultCwd = getDefaultCwd()
const cwd = args.cwd || defaultCwd
// Why: gate on the effective cwd (post default-cwd fallback), not the raw
// args.cwd — an omitted cwd resolves to a safe default and must not be
// rejected as if it were a root-like path.
if (args.command && startupAgentRecognition) {
assertSafeAgentStartupCwd(cwd, args.command)
}
const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null
const worktreeWslContext =
process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined
@@ -594,8 +592,7 @@ export class LocalPtyProvider implements IPtyProvider {
finalEnv.ORCA_OMP_STATUS_EXTENSION ||
finalEnv.ORCA_CODEX_HOME ||
finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR
const isCodexStartupCommand =
recognizeAgentProcessFromCommandLine(args.command)?.agent === 'codex'
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
let shellLaunch: ReturnType<typeof getShellReadyLaunchConfig> | null = null
if (args.command && isCodexStartupCommand) {
const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({
@@ -901,8 +898,19 @@ export class LocalPtyProvider implements IPtyProvider {
async getInitialCwd(_id: string): Promise<string> {
return ''
}
async clearBuffer(_id: string): Promise<void> {
/* handled client-side in xterm.js */
async clearBuffer(id: string): Promise<void> {
// Why: xterm.js clear() only resets the renderer. ConPTY keeps its own
// screen buffer, so without this its stale cursor row makes the next
// prompt repaint land below a blank gap. No-op on POSIX.
//
// Unlike the daemon session, no PSReadLine form-feed nudge here: it is
// only safe at an empty prompt, and without a headless emulator this
// provider cannot tell whether input is pending.
try {
ptyProcesses.get(id)?.clear()
} catch {
/* PTY may have just exited */
}
}
acknowledgeDataEvent(_id: string, _charCount: number): void {
/* no flow control for local */
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn()
}))
vi.mock('node:os', () => ({
homedir: homedirMock
}))
import {
assertSafeAgentStartupCwd,
isSafeImplicitPtyCwd,
resolveSafePtyDefaultCwd
} from './pty-default-cwd'
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform
})
}
describe('pty default cwd safety', () => {
const originalPlatform = process.platform
beforeEach(() => {
setPlatform(originalPlatform)
homedirMock.mockReset()
homedirMock.mockReturnValue('/home/orca')
})
it('rejects root-like implicit cwd values', () => {
expect(isSafeImplicitPtyCwd(undefined)).toBe(false)
expect(isSafeImplicitPtyCwd('')).toBe(false)
expect(isSafeImplicitPtyCwd('/')).toBe(false)
expect(isSafeImplicitPtyCwd('C:\\')).toBe(false)
expect(isSafeImplicitPtyCwd('\\\\server\\share')).toBe(false)
})
it('uses a non-root POSIX home and skips root env values', () => {
setPlatform('linux')
expect(resolveSafePtyDefaultCwd({ HOME: '/home/alice' })).toBe('/home/alice')
expect(resolveSafePtyDefaultCwd({ HOME: '/' })).toBe('/home/orca')
})
it('throws instead of falling back to POSIX root', () => {
setPlatform('darwin')
homedirMock.mockReturnValue('/')
expect(() => resolveSafePtyDefaultCwd({ HOME: '/' })).toThrow(
/No safe default working directory/
)
})
it('uses Windows home candidates without accepting drive roots', () => {
setPlatform('win32')
homedirMock.mockReturnValue('C:\\Users\\os-home')
expect(
resolveSafePtyDefaultCwd({
USERPROFILE: 'C:\\',
HOMEDRIVE: 'D:',
HOMEPATH: '\\Users\\alice'
})
).toBe('D:\\Users\\alice')
})
it('requires automatic agent startup commands to provide a non-root cwd', () => {
expect(() => assertSafeAgentStartupCwd(undefined, 'codex')).toThrow(
/requires a non-root workspace/
)
expect(() => assertSafeAgentStartupCwd('/', 'claude')).toThrow(/requires a non-root workspace/)
expect(() => assertSafeAgentStartupCwd('/repo', 'codex')).not.toThrow()
})
})
+43
View File
@@ -0,0 +1,43 @@
import { homedir } from 'node:os'
import { isRootLikePath } from './pty-path-safety'
function homeDrivePath(env: NodeJS.ProcessEnv): string | null {
if (!env.HOMEDRIVE || !env.HOMEPATH) {
return null
}
return `${env.HOMEDRIVE}${env.HOMEPATH}`
}
export function isSafeImplicitPtyCwd(path: string | null | undefined): path is string {
return !isRootLikePath(path)
}
export function resolveSafePtyDefaultCwd(env: NodeJS.ProcessEnv = process.env): string {
const candidates =
process.platform === 'win32'
? [env.USERPROFILE, homeDrivePath(env), homedir()]
: [env.HOME, homedir()]
const selected = candidates.find(isSafeImplicitPtyCwd)
// Why: silently falling back to "/" or a drive root is the exact runaway-CPU
// bug this module prevents (see runaway-cpu-hidden-usage-pty-design.md) — fail loud.
if (!selected) {
// Log the rejected candidates so the rare "no safe home at all" environment
// (e.g. HOME=/ or unset in a minimal container) is diagnosable, not opaque.
console.error(
'[pty-default-cwd] No safe default working directory for terminal launch; every home candidate resolved to a root-like path.',
{ platform: process.platform, candidates }
)
throw new Error('No safe default working directory is available for terminal launch.')
}
return selected
}
export function assertSafeAgentStartupCwd(cwd: string | undefined, command: string): void {
if (isSafeImplicitPtyCwd(cwd)) {
return
}
throw new Error(
`Automatic agent startup command "${command}" requires a non-root workspace working directory.`
)
}
+17
View File
@@ -0,0 +1,17 @@
import { posix, win32 } from 'node:path'
// Why: both PTY spawn paths (default terminal cwd + hidden usage probes) gate a
// security boundary here — an empty, "/", drive-root, or network-share-root cwd
// triggers the unbounded file discovery this guard exists to prevent. Keep the
// detection in one place so a fix to one enforcement path can't skip the other.
export function isRootLikePath(path: string | null | undefined): boolean {
const trimmed = path?.trim() ?? ''
if (!trimmed) {
return true
}
// A filesystem root is its own parent. Check both POSIX and Windows semantics
// so a Windows drive/UNC root is still rejected when running on POSIX (and vice
// versa) — path.dirname alone only understands the host platform's separators.
const normalized = posix.normalize(trimmed)
return posix.dirname(normalized) === normalized || win32.dirname(trimmed) === trimmed
}
@@ -0,0 +1,127 @@
// Regression guard: bound the volume of full-process-table PowerShell/CIM scans
// driven by Windows agent foreground-process inspection — the Windows analogue of
// issue #6288 (POSIX `ps`).
//
// Drives queryWindowsProcessDescendants across several concurrently-inspecting
// agent panes on the agent-completion cadence (ACTIVE_POLL_INTERVAL_MS = 750ms)
// and counts how many powershell.exe process-table scans actually spawn. Pre-fix
// the call site forked one powershell.exe per pane per tick; with the shared
// snapshot cache the scans collapse to ~one per tick regardless of pane count,
// while each pane still resolves the same descendant set.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, powershellScanCount } = vi.hoisted(() => ({
execFileMock: vi.fn(),
powershellScanCount: { value: 0 }
}))
vi.mock('child_process', () => ({ execFile: execFileMock }))
import {
queryWindowsProcessDescendants,
resetWindowsProcessRowsSnapshotForTests
} from './windows-foreground-process-rows'
const ACTIVE_POLL_INTERVAL_MS = 750
const PANE_COUNT = 6
const WINDOW_SECONDS = 30
const TICKS = Math.floor((WINDOW_SECONDS * 1000) / ACTIVE_POLL_INTERVAL_MS)
const shellPid = (pane: number): number => 100 + pane * 1000
// A real CIM query returns the whole system, so one shared snapshot must contain
// every pane's shell + foreground node/codex child. Each pane resolves its own
// descendant from the single scan.
const PROCESS_TABLE_JSON = JSON.stringify(
Array.from({ length: PANE_COUNT }, (_, pane) => {
const shell = shellPid(pane)
return [
{
ProcessId: shell,
ParentProcessId: 99,
Name: 'cmd.exe',
CommandLine: 'cmd.exe',
ExecutablePath: 'C:/Windows/System32/cmd.exe'
},
{
ProcessId: shell + 1,
ParentProcessId: shell,
Name: 'node.exe',
CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js',
ExecutablePath: 'C:/Program Files/nodejs/node.exe'
}
]
}).flat()
)
function installCountingPowerShellMock(): void {
execFileMock.mockImplementation((cmd: string, _args: unknown, _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
if (cmd === 'powershell.exe') {
powershellScanCount.value += 1
}
callback(null, { stdout: PROCESS_TABLE_JSON, stderr: '' })
})
}
describe('windows agent foreground inspection powershell-scan volume', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetWindowsProcessRowsSnapshotForTests()
powershellScanCount.value = 0
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(0)
})
afterEach(() => {
vi.useRealTimers()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('bounds powershell scans by poll ticks, not by pane count, while resolving every pane', async () => {
installCountingPowerShellMock()
for (let tick = 0; tick < TICKS; tick++) {
vi.setSystemTime(tick * ACTIVE_POLL_INTERVAL_MS)
// All panes inspect concurrently within the tick (worst case).
const resolved = await Promise.all(
Array.from({ length: PANE_COUNT }, (_, pane) =>
queryWindowsProcessDescendants(shellPid(pane))
)
)
// Caching must not change the answer: every pane still finds its foreground
// node child as the sole descendant of its shell.
for (let pane = 0; pane < PANE_COUNT; pane++) {
const candidates = resolved[pane]
expect(candidates).not.toBeNull()
expect(candidates).toHaveLength(1)
expect(candidates?.[0]?.pid).toBe(shellPid(pane) + 1)
}
}
const totalInspections = PANE_COUNT * TICKS
// Pre-fix this equals totalInspections (one powershell.exe per inspection).
// With the shared cache, concurrent panes within a tick share one scan and
// the 500ms TTL forces a fresh scan each new 750ms tick -> ~one per tick.
expect(powershellScanCount.value).toBeLessThanOrEqual(TICKS + 1)
expect(powershellScanCount.value).toBeLessThan(totalInspections / 2)
})
it('collapses a burst of concurrent panes into a single scan', async () => {
installCountingPowerShellMock()
await Promise.all(
Array.from({ length: PANE_COUNT }, (_, pane) =>
queryWindowsProcessDescendants(shellPid(pane))
)
)
expect(powershellScanCount.value).toBe(1)
})
})
@@ -1,5 +1,6 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot'
const execFileAsync = promisify(execFile)
const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000
@@ -22,12 +23,48 @@ export type WindowsProcessRow = {
export type WindowsProcessCandidate = WindowsProcessRow & { depth: number }
// Why: agent foreground inspection forks a whole-process-table PowerShell/CIM
// scan per pane on the same 750ms/2000ms cadence as the POSIX `ps` path. Without
// dedup, K concurrent agent panes fork K powershell.exe cold-starts, each ~10-40x
// heavier than `ps` — the Windows analogue of the idle-CPU churn #6288/#6667 fixed
// for POSIX. Reuse the same TTL + single-in-flight reader, caching parsed rows so
// a burst of panes collapses to ~2 scans/sec; every caller runs its own descendant
// walk over the shared snapshot.
async function runWindowsProcessRows(): Promise<WindowsProcessRow[]> {
const rows =
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
if (!rows) {
// Reject so the reader does not cache the miss; callers fall through to
// node-pty's process name (the prior null-return contract is preserved by
// queryWindowsProcessDescendants catching this).
throw new Error('windows process enumeration unavailable')
}
return rows
}
const windowsProcessRowsReader = createProcessTableSnapshotReader<WindowsProcessRow[]>({
runPs: runWindowsProcessRows,
now: () => Date.now()
})
export async function queryWindowsProcessDescendants(
rootPid: number
): Promise<WindowsProcessCandidate[] | null> {
const rows =
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
return rows ? collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth) : null
let rows: WindowsProcessRow[]
try {
rows = await windowsProcessRowsReader.getSnapshot()
} catch {
return null
}
return collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth)
}
/**
* Test-only: clear the shared Windows process-table snapshot so suites that mock
* execFile between cases don't get one case's rows served to the next within TTL.
*/
export function resetWindowsProcessRowsSnapshotForTests(): void {
windowsProcessRowsReader.reset()
}
function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] {
@@ -0,0 +1,162 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: vi.fn(() => '/unused'),
getVersion: vi.fn(() => '0.0.0-test')
}
}))
import {
ensureRelocatedNodePtyNativeRuntime,
resolveNodePtyNativeSourceDir
} from './node-pty-runtime-relocation'
let tempDir: string
beforeEach(() => {
tempDir = mkdtempSync(join(os.tmpdir(), 'node-pty-relocation-'))
})
afterEach(() => {
// Why: the loader-override test dlopens conpty.node from tempDir, and a
// loaded native module stays image-locked until the process exits.
try {
rmSync(tempDir, { recursive: true, force: true })
} catch {}
})
function seedSourceDir(dir: string): void {
mkdirSync(join(dir, 'conpty'), { recursive: true })
writeFileSync(join(dir, 'conpty.node'), 'binding')
writeFileSync(join(dir, 'conpty_console_list.node'), 'listing')
writeFileSync(join(dir, 'pty.node'), 'winpty-binding')
writeFileSync(join(dir, 'winpty-agent.exe'), 'agent')
writeFileSync(join(dir, 'conpty.pdb'), 'symbols')
writeFileSync(join(dir, 'conpty', 'conpty.dll'), 'dll')
writeFileSync(join(dir, 'conpty', 'OpenConsole.exe'), 'console-host')
}
describe('resolveNodePtyNativeSourceDir', () => {
it('prefers the rebuilt build/Release binding over prebuilds', () => {
const pkg = join(tempDir, 'node-pty')
mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
writeFileSync(join(pkg, 'build', 'Release', 'conpty.node'), 'x')
writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'build', 'Release'))
})
it('falls back to the platform prebuild dir', () => {
const pkg = join(tempDir, 'node-pty')
mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'prebuilds', `win32-${process.arch}`))
})
it('returns null when no conpty binding exists', () => {
const pkg = join(tempDir, 'node-pty')
mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
expect(resolveNodePtyNativeSourceDir(pkg)).toBeNull()
})
})
describe('ensureRelocatedNodePtyNativeRuntime', () => {
it('copies the runtime tree (without symbols) and returns the version dir', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
expect(destDir).toBe(join(destRoot, '1.2.3'))
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
expect(readFileSync(join(destDir!, 'conpty', 'OpenConsole.exe'), 'utf8')).toBe('console-host')
expect(readFileSync(join(destDir!, 'conpty', 'conpty.dll'), 'utf8')).toBe('dll')
expect(existsSync(join(destDir!, 'conpty.pdb'))).toBe(false)
})
it('skips recopying once the completion marker exists', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy')
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
it('redoes an interrupted copy that has no completion marker', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
mkdirSync(join(destRoot, '1.2.3'), { recursive: true })
writeFileSync(join(destRoot, '1.2.3', 'conpty.node'), 'torn partial copy')
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
it('removes stale version dirs but keeps the current one', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0' })
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '2.0.0' })
expect(destDir).toBe(join(destRoot, '2.0.0'))
expect(existsSync(join(destRoot, '1.0.0'))).toBe(false)
expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true)
})
it('fails open when the source dir is missing', () => {
expect(
ensureRelocatedNodePtyNativeRuntime({
sourceDir: join(tempDir, 'does-not-exist'),
destRoot: join(tempDir, 'dest'),
version: '1.2.3'
})
).toBeNull()
})
})
// Loads the real win32 conpty binding, so it cannot run on other platforms.
describe.runIf(process.platform === 'win32')('patched node-pty loader override', () => {
it('loads the conpty binding from ORCA_NODE_PTY_NATIVE_DIR', () => {
const requireFromHere = createRequire(import.meta.url)
const nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
expect(sourceDir).not.toBeNull()
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir: sourceDir!,
destRoot: join(tempDir, 'relocated-runtime'),
version: 'loader-test'
})
expect(destDir).not.toBeNull()
const previousNativeDir = process.env.ORCA_NODE_PTY_NATIVE_DIR
process.env.ORCA_NODE_PTY_NATIVE_DIR = destDir!
try {
const utils = requireFromHere('node-pty/lib/utils.js')
const loaded = utils.loadNativeModule('conpty')
expect(loaded.dir).toBe(destDir)
expect(typeof loaded.module.startProcess).toBe('function')
} finally {
if (previousNativeDir === undefined) {
delete process.env.ORCA_NODE_PTY_NATIVE_DIR
} else {
process.env.ORCA_NODE_PTY_NATIVE_DIR = previousNativeDir
}
}
})
})
+137
View File
@@ -0,0 +1,137 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { app } from 'electron'
/**
* Relocates node-pty's Windows native runtime (conpty.node, conpty.dll,
* OpenConsole.exe, winpty binaries) from the install directory to userData.
*
* Why: the NSIS update installer force-closes every process whose image path
* is under the install directory before replacing files. conpty.dll spawns
* OpenConsole.exe from beside itself, so while these binaries live in the
* install dir every live terminal's console host is killed mid-update.
* Loading them from userData (via the ORCA_NODE_PTY_NATIVE_DIR override
* patched into node-pty's loader) takes them out of the installer's kill
* zone, and the detached daemon inherits the env var so its PTYs are covered.
*/
export const NODE_PTY_NATIVE_DIR_ENV_VAR = 'ORCA_NODE_PTY_NATIVE_DIR'
const RELOCATION_COMPLETE_MARKER = '.relocation-complete'
export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string | null {
// Packaged builds carry the rebuilt binding in build/Release; dev installs
// (and the forced-relocation test path) load from prebuilds.
const candidates = [
join(nodePtyPackageDir, 'build', 'Release'),
join(nodePtyPackageDir, 'prebuilds', `win32-${process.arch}`)
]
for (const candidate of candidates) {
if (existsSync(join(candidate, 'conpty.node'))) {
return candidate
}
}
return null
}
function copyRuntimeTree(sourceDir: string, destDir: string): void {
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = join(sourceDir, entry.name)
const destPath = join(destDir, entry.name)
if (entry.isDirectory()) {
copyRuntimeTree(sourcePath, destPath)
} else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) {
copyFileSync(sourcePath, destPath)
}
}
}
function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void {
let entries
try {
entries = readdirSync(destRoot, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === keepVersion) {
continue
}
// Why: an adopted daemon from a previous version may still run (or later
// respawn) binaries out of its own version dir. Renaming a directory
// fails on Windows while anything inside is open, so a successful rename
// proves the dir is unused and safe to delete.
const doomedPath = join(destRoot, `${entry.name}.stale`)
try {
renameSync(join(destRoot, entry.name), doomedPath)
rmSync(doomedPath, { recursive: true, force: true })
} catch {
// Still in use (or already being cleaned) — retry on a future launch.
}
}
}
export function ensureRelocatedNodePtyNativeRuntime(options: {
sourceDir: string
destRoot: string
version: string
}): string | null {
const { sourceDir, destRoot, version } = options
const destDir = join(destRoot, version)
try {
// Why: the marker is written only after a full copy, so a crash mid-copy
// leaves no marker and the next launch redoes the copy from scratch.
if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) {
rmSync(destDir, { recursive: true, force: true })
copyRuntimeTree(sourceDir, destDir)
writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '')
}
removeStaleRuntimeVersions(destRoot, version)
return destDir
} catch {
// Fail open: node-pty keeps loading from the install dir, which is the
// pre-relocation behavior (sessions then don't survive updates).
return null
}
}
export function installRelocatedNodePtyNativeRuntime(): void {
if (process.platform !== 'win32') {
return
}
if (!app.isPackaged && process.env.ORCA_FORCE_CONPTY_RELOCATION !== '1') {
return
}
// Note: a pre-set env var is deliberately NOT honored here. The update
// relaunch chain (old app -> installer -> new app) inherits the previous
// version's value, which would pin the new process to stale binaries and
// skip copying the current version.
let nodePtyPackageDir: string
try {
const requireFromHere = createRequire(import.meta.url)
nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
} catch {
return
}
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
if (!sourceDir) {
return
}
const destDir = ensureRelocatedNodePtyNativeRuntime({
sourceDir,
destRoot: join(app.getPath('userData'), 'node-pty-runtime'),
version: app.getVersion()
})
if (destDir) {
process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir
}
}
+102 -14
View File
@@ -312,6 +312,17 @@ type ClaudeUsageAttemptState = {
attemptedSources: UsageRateLimitSource[]
}
function abortedClaudeRateLimitResult(): ProviderRateLimits {
return {
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: 'Rate-limit fetch aborted',
status: 'error'
}
}
function parseResetTimestamp(value: string | number | undefined): number | null {
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
@@ -389,11 +400,21 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null
)
}
async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise<ProviderRateLimits> {
if (signal?.aborted) {
return abortedClaudeRateLimitResult()
}
await ensureProxyFromEnv()
if (signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
const abortFromExternalSignal = (): void => {
controller.abort()
}
signal?.addEventListener('abort', abortFromExternalSignal, { once: true })
try {
// Why: net.fetch uses Chromium's networking stack which respects OS proxy
@@ -414,6 +435,9 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
}
const data = (await res.json()) as OAuthUsageResponse
if (signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return {
provider: 'claude',
@@ -424,8 +448,14 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
error: null,
status: 'ok'
}
} catch (err) {
if (signal?.aborted) {
return abortedClaudeRateLimitResult()
}
throw err
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abortFromExternalSignal)
}
}
@@ -507,11 +537,13 @@ async function fetchClaudeUsageViaCli(input: {
oauthCredentials: OAuthCredentialReadResult
attempts: ClaudeUsageAttemptState
networkProxySettings?: NetworkProxySettings
signal?: AbortSignal
}): Promise<ProviderRateLimits> {
recordAttempt(input.attempts, 'cli')
const limits = await fetchViaPty({
authPreparation: input.authPreparation,
networkProxySettings: input.networkProxySettings
networkProxySettings: input.networkProxySettings,
signal: input.signal
})
return withClaudeUsageMetadata(
limits,
@@ -567,8 +599,9 @@ async function supplementOAuthUsageFromCli(input: {
attempts: ClaudeUsageAttemptState
allowUsagePanelSupplement: boolean
networkProxySettings?: NetworkProxySettings
signal?: AbortSignal
}): Promise<ProviderRateLimits> {
if (!canSupplementOAuthUsageFromCli(input)) {
if (input.signal?.aborted || !canSupplementOAuthUsageFromCli(input)) {
return input.oauthLimits
}
try {
@@ -576,7 +609,8 @@ async function supplementOAuthUsageFromCli(input: {
authPreparation: input.authPreparation,
oauthCredentials: input.oauthCredentials,
attempts: input.attempts,
networkProxySettings: input.networkProxySettings
networkProxySettings: input.networkProxySettings,
signal: input.signal
})
return mergeClaudeUsageWindows(input.oauthLimits, cliLimits)
} catch (err) {
@@ -637,25 +671,40 @@ async function attemptCliRepairThenRetryOAuth(input: {
attempts: ClaudeUsageAttemptState
oauthCredentials: OAuthCredentialReadResult
}): Promise<ProviderRateLimits | null> {
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
let cliResult: ProviderRateLimits | null = null
try {
cliResult = await fetchClaudeUsageViaCli({
authPreparation: input.options?.authPreparation,
oauthCredentials: input.oauthCredentials,
attempts: input.attempts,
networkProxySettings: input.options?.networkProxySettings
networkProxySettings: input.options?.networkProxySettings,
signal: input.options?.signal
})
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, err)
}
// Why: bail before credential I/O if the fetch cycle was stopped mid-CLI-repair.
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const refreshedCredentials = await readOAuthCredentials(
resolveOAuthCredentialReadOptions(input.options?.authPreparation)
)
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
if (refreshedCredentials.token) {
recordAttempt(input.attempts, 'oauth')
try {
const oauthRetry = await fetchViaOAuth(refreshedCredentials.token)
const oauthRetry = await fetchViaOAuth(refreshedCredentials.token, input.options?.signal)
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const supplemented = mergeClaudeUsageWindows(oauthRetry, cliResult)
return withClaudeUsageMetadata(
supplemented,
@@ -683,16 +732,21 @@ export type FetchClaudeRateLimitsOptions = {
allowPtyFallback?: boolean
allowUsagePanelSupplement?: boolean
networkProxySettings?: NetworkProxySettings
signal?: AbortSignal
}
export type FetchManagedAccountUsageOptions = {
allowUsagePanelSupplement?: boolean
networkProxySettings?: NetworkProxySettings
signal?: AbortSignal
}
export async function fetchClaudeRateLimits(
options?: FetchClaudeRateLimitsOptions
): Promise<ProviderRateLimits> {
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const attempts: ClaudeUsageAttemptState = { attemptedSources: [] }
const allowCliFallback = options?.allowPtyFallback !== false
const plan = resolveClaudeUsageRefreshPlan({
@@ -715,11 +769,17 @@ export async function fetchClaudeRateLimits(
const oauthCredentials = await readOAuthCredentials(
resolveOAuthCredentialReadOptions(options?.authPreparation)
)
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) {
recordAttempt(attempts, 'oauth')
try {
const oauthLimits = await fetchViaOAuth(oauthCredentials.token)
const oauthLimits = await fetchViaOAuth(oauthCredentials.token, options?.signal)
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const limits = await supplementOAuthUsageFromCli({
oauthLimits,
authPreparation: options?.authPreparation,
@@ -727,8 +787,12 @@ export async function fetchClaudeRateLimits(
attempts,
networkProxySettings: options?.networkProxySettings,
allowUsagePanelSupplement:
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation)
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation),
signal: options?.signal
})
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
@@ -767,7 +831,8 @@ export async function fetchClaudeRateLimits(
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings
networkProxySettings: options?.networkProxySettings,
signal: options?.signal
})
} catch (ptyError) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError)
@@ -825,7 +890,8 @@ export async function fetchClaudeRateLimits(
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings
networkProxySettings: options?.networkProxySettings,
signal: options?.signal
})
} catch (err) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
@@ -871,7 +937,8 @@ export async function fetchClaudeRateLimits(
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings
networkProxySettings: options?.networkProxySettings,
signal: options?.signal
})
} catch (err) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
@@ -1079,7 +1146,11 @@ async function fetchManagedUsagePanelSupplement(input: {
credentialsJson: string
oauthLimits: ProviderRateLimits
networkProxySettings?: NetworkProxySettings
signal?: AbortSignal
}): Promise<ProviderRateLimits | null> {
if (input.signal?.aborted) {
return null
}
const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location)
if (!authPreparation) {
return null
@@ -1087,8 +1158,12 @@ async function fetchManagedUsagePanelSupplement(input: {
return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => {
const cliLimits = await fetchViaPty({
authPreparation,
networkProxySettings: input.networkProxySettings
networkProxySettings: input.networkProxySettings,
signal: input.signal
})
if (input.signal?.aborted) {
return null
}
if (
!canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, {
requireMatchingOAuthWindow: input.location.kind === 'keychain'
@@ -1108,8 +1183,14 @@ export async function fetchManagedAccountUsage(
account: InactiveClaudeAccountInfo,
options: FetchManagedAccountUsageOptions = {}
): Promise<ProviderRateLimits> {
if (options.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const location = resolveManagedCredentialsLocation(account)
let credentialsJson = location ? await readManagedCredentialsJson(location) : null
if (options.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
if (!location || !credentialsJson) {
return {
provider: 'claude',
@@ -1129,6 +1210,9 @@ export async function fetchManagedAccountUsage(
let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token
if (isOauthTokenExpiring(credentialsJson)) {
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
if (options.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
if (refreshed) {
try {
await writeManagedCredentialsJson(location, refreshed)
@@ -1155,7 +1239,10 @@ export async function fetchManagedAccountUsage(
// Why: PTY fallback is intentionally omitted for inactive accounts. The PTY
// path is used only as a supplement after OAuth succeeds, and it points
// directly at the managed account's isolated config so selection is unchanged.
const oauthLimits = await fetchViaOAuth(token)
const oauthLimits = await fetchViaOAuth(token, options.signal)
if (options.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
if (
!canSupplementOAuthUsageFromCli({
oauthLimits,
@@ -1171,7 +1258,8 @@ export async function fetchManagedAccountUsage(
location,
credentialsJson,
oauthLimits,
networkProxySettings: options.networkProxySettings
networkProxySettings: options.networkProxySettings,
signal: options.signal
})
return mergeClaudeUsageWindows(oauthLimits, cliLimits)
} catch (err) {
+41
View File
@@ -14,6 +14,7 @@ vi.mock('node-pty', () => ({
}))
import { fetchViaPty } from './claude-pty'
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
function makeDisposable() {
return { dispose: vi.fn() }
@@ -97,6 +98,22 @@ describe('fetchViaPty', () => {
await resultPromise
})
it('spawns the hidden usage PTY in a bounded non-root cwd', async () => {
const term = makeMockTerm()
spawnMock.mockReturnValue(term)
const resultPromise = fetchViaPty()
await vi.advanceTimersByTimeAsync(0)
const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string
expect(spawnCwd).toContain('rate-limit-pty-cwd')
expect(spawnCwd).not.toBe('/')
expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/)
term.emitExit()
await resultPromise
})
it('leaves the spawn env proxy untouched when no proxy is configured', async () => {
const term = makeMockTerm()
spawnMock.mockReturnValue(term)
@@ -135,6 +152,8 @@ describe('fetchViaPty', () => {
const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]]
expect(spawnFile).toBe('wsl.exe')
const bashCommand = spawnArgs.at(-1) as string
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'")
expect(bashCommand).toContain('exec claude')
@@ -142,6 +161,28 @@ describe('fetchViaPty', () => {
await resultPromise
})
it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => {
const term = makeMockTerm()
spawnMock.mockReturnValue(term)
const controller = new AbortController()
const killMock = term.kill
const resultPromise = fetchViaPty({ signal: controller.signal })
await vi.advanceTimersByTimeAsync(0)
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
controller.abort()
await expect(resultPromise).resolves.toMatchObject({
provider: 'claude',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(killMock).toHaveBeenCalledTimes(1)
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
})
it('clears the startup delay timer when the hidden PTY exits early', async () => {
const term = makeMockTerm()
spawnMock.mockReturnValue(term)
+66 -6
View File
@@ -7,8 +7,12 @@ import { resolveClaudeCommand } from '../codex-cli/command'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser'
import {
getHiddenRateLimitWslCwdSetupCommands,
resolveHiddenRateLimitPtyCwd
} from './hidden-rate-limit-pty-cwd'
const PTY_TIMEOUT_MS = 25_000
const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit
@@ -197,11 +201,29 @@ function describeClaudeUsageFailure(output: string): string {
return 'Claude usage is unavailable right now.'
}
function abortedClaudeUsageResult(): ProviderRateLimits {
return {
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: 'Rate-limit fetch aborted',
status: 'error'
}
}
export async function fetchViaPty(options?: {
authPreparation?: ClaudeRuntimeAuthPreparation
networkProxySettings?: NetworkProxySettings
signal?: AbortSignal
}): Promise<ProviderRateLimits> {
if (options?.signal?.aborted) {
return abortedClaudeUsageResult()
}
const pty = await import('node-pty')
if (options?.signal?.aborted) {
return abortedClaudeUsageResult()
}
return new Promise<ProviderRateLimits>((resolve) => {
let output = ''
@@ -251,10 +273,13 @@ export async function fetchViaPty(options?: {
// Why: Windows-side env does not cross into the distro without WSLENV,
// so export the configured proxy inside the command for the inner claude.
[
// Why: hidden usage probes must not inherit a root-like WSL cwd;
// keep Claude discovery bounded to a tiny temp directory.
...getHiddenRateLimitWslCwdSetupCommands(),
`export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`,
...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`),
'exec claude'
].join('; ')
].join(' && ')
]
: isWin32
? ['/c', `"${claudeCommand}"`]
@@ -264,10 +289,14 @@ export async function fetchViaPty(options?: {
name: 'xterm-256color',
cols: 120,
rows: 40,
// Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a
// drive root), which can trigger unbounded file discovery.
cwd: resolveHiddenRateLimitPtyCwd(),
env: spawnEnv
})
const termDisposables: { dispose: () => void }[] = []
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
let enterInterval: ReturnType<typeof setInterval> | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
function clearFollowupTimers(): void {
if (startupDelayTimer) {
@@ -288,7 +317,32 @@ export async function fetchViaPty(options?: {
}
}
const timeout = setTimeout(() => {
function settleAborted(): void {
if (resolved) {
return
}
resolved = true
if (timeout) {
clearTimeout(timeout)
timeout = null
}
clearFollowupTimers()
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
resolve(abortedClaudeUsageResult())
}
if (options?.signal) {
if (options.signal.aborted) {
settleAborted()
return
}
options.signal.addEventListener('abort', settleAborted, { once: true })
termDisposables.push({
dispose: () => options.signal?.removeEventListener('abort', settleAborted)
})
}
timeout = setTimeout(() => {
if (!resolved) {
resolved = true
clearFollowupTimers()
@@ -342,7 +396,10 @@ export async function fetchViaPty(options?: {
return
}
resolved = true
clearTimeout(timeout)
if (timeout) {
clearTimeout(timeout)
timeout = null
}
clearFollowupTimers()
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
@@ -443,7 +500,10 @@ export async function fetchViaPty(options?: {
clearFollowupTimers()
if (!resolved) {
resolved = true
clearTimeout(timeout)
if (timeout) {
clearTimeout(timeout)
timeout = null
}
const clean = stripTerminalControlSequences(output)
const { session, weekly, fableWeekly } = parsePtyUsage(clean)
resolve({
+119 -20
View File
@@ -33,6 +33,7 @@ vi.mock('./codex-auth-presence', () => ({
import { fetchCodexRateLimits } from './codex-fetcher'
import { codexAuthExists } from './codex-auth-presence'
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
function makeDisposable() {
return { dispose: vi.fn() }
@@ -52,6 +53,25 @@ function makeRpcChild() {
return child
}
function makePtyTerm() {
let dataHandler: ((data: string) => void) | null = null
let exitHandler: (() => void) | null = null
return {
onData: vi.fn((callback: (data: string) => void) => {
dataHandler = callback
return makeDisposable()
}),
onExit: vi.fn((callback: () => void) => {
exitHandler = callback
return makeDisposable()
}),
write: vi.fn(),
kill: vi.fn(),
emitData: (data: string) => dataHandler?.(data),
emitExit: () => exitHandler?.()
}
}
describe('fetchCodexRateLimits', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -108,6 +128,83 @@ describe('fetchCodexRateLimits', () => {
)
})
it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
expect(spawnCwd).toContain('rate-limit-pty-cwd')
expect(spawnCwd).not.toBe('/')
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
rpcChild.emit('close')
await resultPromise
})
it('spawns the PTY fallback in a bounded non-root cwd', async () => {
const term = makePtyTerm()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue(term)
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string
expect(spawnCwd).toContain('rate-limit-pty-cwd')
expect(spawnCwd).not.toBe('/')
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
term.emitExit()
await resultPromise
})
it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const controller = new AbortController()
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
controller.abort()
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => {
const term = makePtyTerm()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue(term)
const controller = new AbortController()
const killMock = term.kill
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
await vi.advanceTimersByTimeAsync(0)
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
controller.abort()
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(killMock).toHaveBeenCalledTimes(1)
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
})
it('falls back to the PTY status reader when RPC exits before returning usage', async () => {
const rpcChild = makeRpcChild()
const ptyHandlers: { onData?: (data: string) => void } = {}
@@ -437,17 +534,18 @@ describe('fetchCodexRateLimits', () => {
await vi.advanceTimersByTimeAsync(1)
await resultPromise
expect(childSpawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu',
'--',
'bash',
'-lc',
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'"
],
const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
expect(spawnFile).toBe('wsl.exe')
const bashCommand = spawnArgs.at(-1) as string
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
expect(bashCommand).toContain(
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'"
)
expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'")
expect(spawnOptions).toEqual(
expect.objectContaining({
cwd: expect.stringContaining('rate-limit-pty-cwd'),
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
})
)
@@ -488,17 +586,18 @@ describe('fetchCodexRateLimits', () => {
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
expect(ptySpawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu',
'--',
'bash',
'-lc',
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex "
],
const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0]
expect(spawnFile).toBe('wsl.exe')
const bashCommand = spawnArgs.at(-1) as string
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
expect(bashCommand).toContain(
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'"
)
expect(bashCommand).toContain('exec codex ')
expect(spawnOptions).toEqual(
expect.objectContaining({
cwd: expect.stringContaining('rate-limit-pty-cwd'),
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
})
)
+119 -12
View File
@@ -14,9 +14,13 @@ import { codexAuthExists } from './codex-auth-presence'
import { resolveCodexCommand } from '../codex-cli/command'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors'
import {
getHiddenRateLimitWslCwdSetupCommands,
resolveHiddenRateLimitPtyCwd
} from './hidden-rate-limit-pty-cwd'
const RPC_TIMEOUT_MS = 10_000
const WSL_RPC_TIMEOUT_MS = 25_000
@@ -26,6 +30,7 @@ const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
export type FetchCodexRateLimitsOptions = {
codexHomePath?: string | null
allowPtyFallback?: boolean
signal?: AbortSignal
}
// ---------------------------------------------------------------------------
@@ -117,9 +122,10 @@ function buildWslCodexCommand(
return null
}
const script = [
...getHiddenRateLimitWslCwdSetupCommands(),
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`,
`exec codex ${args.map(shellQuote).join(' ')}`
].join('; ')
].join(' && ')
return {
command: 'wsl.exe',
args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script]
@@ -161,6 +167,17 @@ function normalizeCreditStatus(status: string | undefined): string {
return status?.toLowerCase() ?? 'unknown'
}
function abortedCodexRateLimitResult(): ProviderRateLimits {
return {
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error: 'Rate-limit fetch aborted',
status: 'error'
}
}
function getNextAvailableCreditExpiry(
credits: RateLimitResetCredits['credits'] | undefined
): number | null {
@@ -228,6 +245,9 @@ function mapBackendRateLimitResetCredits(
async function getCodexBackendAuthHeaders(
options?: FetchCodexRateLimitsOptions
): Promise<CodexBackendAuthHeaders | null> {
if (options?.signal?.aborted) {
return null
}
const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json')
const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile
const accessToken = auth.tokens?.access_token
@@ -250,16 +270,22 @@ async function getCodexBackendAuthHeaders(
async function fetchBackendRateLimitResetCredits(
options?: FetchCodexRateLimitsOptions
): Promise<RateLimitResetCredits | null> {
if (options?.signal?.aborted) {
return null
}
const auth = await getCodexBackendAuthHeaders(options)
if (!auth) {
return null
}
if (options?.signal?.aborted) {
return null
}
// Why: published Codex 0.140 can read windows through app-server but strips
// reset-credit metadata that the backend already returns.
const response = await fetch(
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
auth
)
const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', {
...auth,
signal: options?.signal
})
if (!response.ok) {
return null
}
@@ -272,6 +298,7 @@ async function withBackendRateLimitResetCredits(
options?: FetchCodexRateLimitsOptions
): Promise<ProviderRateLimits> {
if (
options?.signal?.aborted ||
limits.provider !== 'codex' ||
(limits.rateLimitResetCredits?.nextExpiresAt !== undefined &&
limits.rateLimitResetCredits.nextExpiresAt !== null)
@@ -373,6 +400,9 @@ function mapRpcWindow(
// ---------------------------------------------------------------------------
async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
return new Promise<ProviderRateLimits>((resolve) => {
let buffer = ''
let stderr = ''
@@ -396,6 +426,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
: getSpawnArgsForWindows(codexCommand, codexArgs)
const child = spawn(spawnCmd, spawnArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: resolveHiddenRateLimitPtyCwd(),
// Why: the selected Codex rate-limit account must only affect this fetch
// subprocess. Never mutate process.env globally or other Codex features
// would inherit the managed account unintentionally.
@@ -416,6 +447,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
clearTimeout(timeout)
timeout = null
}
options?.signal?.removeEventListener('abort', onAbort)
child.stdout.off('data', onStdoutData)
child.stderr.off('data', onStderrData)
child.off('error', onError)
@@ -434,6 +466,18 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
resolve(result)
}
function onAbort(): void {
settle(abortedCodexRateLimitResult(), { kill: true })
}
if (options?.signal) {
if (options.signal.aborted) {
onAbort()
return
}
options.signal.addEventListener('abort', onAbort, { once: true })
}
timeout = setTimeout(() => {
settle(
{
@@ -632,7 +676,13 @@ function parsePtyStatus(output: string): {
}
async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const pty = await import('node-pty')
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
@@ -652,20 +702,50 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
let resolved = false
let sentStatus = false
let settleTimer: ReturnType<typeof setTimeout> | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
const term = pty.spawn(spawnFile, spawnArgs, {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: resolveHiddenRateLimitPtyCwd(),
env: {
...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env),
TERM: 'xterm-256color',
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
}
})
const termDisposables: { dispose: () => void }[] = []
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
const timeout = setTimeout(() => {
function settleAborted(): void {
if (resolved) {
return
}
resolved = true
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
resolve(abortedCodexRateLimitResult())
}
if (options?.signal) {
if (options.signal.aborted) {
settleAborted()
return
}
options.signal.addEventListener('abort', settleAborted, { once: true })
termDisposables.push({
dispose: () => options.signal?.removeEventListener('abort', settleAborted)
})
}
timeout = setTimeout(() => {
if (!resolved) {
resolved = true
if (settleTimer) {
@@ -709,7 +789,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
return
}
resolved = true
clearTimeout(timeout)
if (timeout) {
clearTimeout(timeout)
timeout = null
}
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
// eslint-disable-next-line no-control-regex
@@ -742,7 +825,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
}
if (!resolved) {
resolved = true
clearTimeout(timeout)
if (timeout) {
clearTimeout(timeout)
timeout = null
}
// eslint-disable-next-line no-control-regex
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const { session, weekly } = parsePtyStatus(clean)
@@ -773,6 +859,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
export async function fetchCodexRateLimits(
options?: FetchCodexRateLimitsOptions
): Promise<ProviderRateLimits> {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
// Why: never spawn the `codex` binary unless the user has signed in. Without
// auth the RPC/PTY paths can only error, and spawning them shows up as an
// unexpected background Codex process for users who don't use Codex.
@@ -790,8 +879,12 @@ export async function fetchCodexRateLimits(
// Path A: try RPC first
try {
const rpcResult = await fetchViaRpc(options)
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') {
return await withBackendRateLimitResetCredits(rpcResult, options)
const withResetCredits = await withBackendRateLimitResetCredits(rpcResult, options)
return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
}
if (isCodexAuthError(rpcResult.error)) {
return rpcResult
@@ -802,6 +895,9 @@ export async function fetchCodexRateLimits(
// Why: app-server can fail independently of the interactive CLI. Keep the
// status-bar useful by trying the older /status PTY reader on RPC errors.
} catch {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
if (options?.allowPtyFallback === false) {
return {
provider: 'codex',
@@ -817,8 +913,19 @@ export async function fetchCodexRateLimits(
// Path B: PTY fallback
try {
return await withBackendRateLimitResetCredits(await fetchViaPty(options), options)
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const ptyResult = await fetchViaPty(options)
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const withResetCredits = await withBackendRateLimitResetCredits(ptyResult, options)
return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
} catch (err) {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const message = err instanceof Error ? err.message : 'Unknown error'
const isNotInstalled = message.includes('ENOENT')
return {
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
import {
cleanupHiddenRateLimitPty,
getActiveHiddenRateLimitPtyCount,
registerHiddenRateLimitPty
} from './hidden-pty-cleanup'
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
@@ -98,4 +102,34 @@ describe('cleanupHiddenRateLimitPty', () => {
expect(term.kill).toHaveBeenCalledTimes(1)
expect(term.destroy).toHaveBeenCalledTimes(1)
})
it('removes registered hidden PTYs when cleanup kills them', () => {
setPlatform('darwin')
const term = {
kill: vi.fn(),
destroy: vi.fn()
}
const registration = registerHiddenRateLimitPty(term)
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
cleanupHiddenRateLimitPty(term, [registration], { kill: true })
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
})
it('removes registered hidden PTYs after natural exit cleanup', () => {
setPlatform('darwin')
const term = {
kill: vi.fn(),
destroy: vi.fn()
}
const registration = registerHiddenRateLimitPty(term)
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
cleanupHiddenRateLimitPty(term, [registration], { kill: false })
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
})
})
@@ -7,6 +7,21 @@ type Disposable = {
dispose: () => void
}
const activeHiddenRateLimitPtys = new Set<HiddenPty>()
export function registerHiddenRateLimitPty(term: HiddenPty): Disposable {
activeHiddenRateLimitPtys.add(term)
return {
dispose: () => {
activeHiddenRateLimitPtys.delete(term)
}
}
}
export function getActiveHiddenRateLimitPtyCount(): number {
return activeHiddenRateLimitPtys.size
}
export function cleanupHiddenRateLimitPty(
term: HiddenPty,
disposables: Disposable[],
@@ -0,0 +1,37 @@
import { mkdirSync, realpathSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { isRootLikePath } from '../providers/pty-path-safety'
const HIDDEN_RATE_LIMIT_PTY_CWD_DIR = 'rate-limit-pty-cwd'
const WSL_RATE_LIMIT_PTY_CWD_DIR = 'orca-rate-limit-pty-cwd'
// Why: the hidden usage PTY must run in a bounded, never-root directory so
// Claude's discovery cannot walk a whole filesystem — reject a root-like user
// data path and scope to tmpdir instead (see runaway-cpu-hidden-usage-pty-design.md).
function resolveUserDataRoot(userDataPath?: string | null): string {
const root = userDataPath?.trim() || process.env.ORCA_USER_DATA_PATH?.trim()
if (root && !isRootLikePath(root)) {
return root
}
return join(tmpdir(), 'orca-rate-limit-pty')
}
export function resolveHiddenRateLimitPtyCwd(options?: { userDataPath?: string | null }): string {
const cwd = join(resolveUserDataRoot(options?.userDataPath), HIDDEN_RATE_LIMIT_PTY_CWD_DIR)
mkdirSync(cwd, { recursive: true })
const realCwd = realpathSync(cwd)
if (isRootLikePath(realCwd) || !statSync(realCwd).isDirectory()) {
throw new Error(`Hidden rate-limit PTY cwd is not a safe directory: ${realCwd}`)
}
return realCwd
}
export function getHiddenRateLimitWslCwdSetupCommands(): string[] {
return [
`orca_rate_limit_cwd="\${TMPDIR:-/tmp}/${WSL_RATE_LIMIT_PTY_CWD_DIR}"`,
'mkdir -p "$orca_rate_limit_cwd"',
'cd "$orca_rate_limit_cwd"'
]
}
@@ -0,0 +1,500 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
() => {
const netFetchMock = vi.fn()
const cookiesSetMock = vi.fn(() => Promise.resolve())
const clearStorageDataMock = vi.fn(() => Promise.resolve())
const sessionFromPartitionMock = vi.fn(() => ({
clearStorageData: clearStorageDataMock,
cookies: { set: cookiesSetMock },
fetch: netFetchMock
}))
return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
}
)
vi.mock('electron', () => ({
net: { fetch: netFetchMock },
session: { fromPartition: sessionFromPartitionMock }
}))
import {
extractMiniMaxCookieValue,
fetchMiniMaxRateLimits,
normalizeMiniMaxCookieHeader,
redactMiniMaxSecret
} from './minimax-fetcher'
const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
function makeResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body
} as Response
}
function makeOkPayload(remainingPercent: number): unknown {
const now = Date.now()
return {
base_resp: { status_code: 0, status_msg: 'ok' },
model_remains: [
{
model_name: 'general',
current_interval_remaining_percent: remainingPercent,
start_time: now - 60_000,
end_time: now + 5 * 60 * 60 * 1000,
remains_time: 5 * 60 * 60 * 1000
}
]
}
}
const FULL_COOKIE =
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
function getCookieJarSetNames(): string[] {
return cookiesSetMock.mock.calls.map((call) => {
const [details] = call as unknown as [{ name: string }]
return details.name
})
}
describe('fetchMiniMaxRateLimits', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z'))
clearStorageDataMock.mockClear()
cookiesSetMock.mockClear()
netFetchMock.mockReset()
sessionFromPartitionMock.mockClear()
sessionFromPartitionMock.mockImplementation(() => ({
clearStorageData: clearStorageDataMock,
cookies: { set: cookiesSetMock },
fetch: netFetchMock
}))
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('returns unavailable when cookie is empty', async () => {
const result = await fetchMiniMaxRateLimits({ cookie: '' })
expect(result.status).toBe('unavailable')
expect(result.provider).toBe('minimax')
expect(result.session).toBeNull()
expect(result.weekly).toBeNull()
expect(result.error).toMatch(/not configured/i)
expect(netFetchMock).not.toHaveBeenCalled()
expect(cookiesSetMock).not.toHaveBeenCalled()
})
it('returns unavailable when cookie is only whitespace', async () => {
const result = await fetchMiniMaxRateLimits({ cookie: ' ' })
expect(result.status).toBe('unavailable')
expect(netFetchMock).not.toHaveBeenCalled()
expect(cookiesSetMock).not.toHaveBeenCalled()
})
it('returns error when cookie has no _token', async () => {
const result = await fetchMiniMaxRateLimits({
cookie: '_twpid=tw.123; minimax_group_id_v2=12345'
})
expect(result.status).toBe('error')
expect(result.error).toMatch(/MiniMax auth cookie not found/)
expect(netFetchMock).not.toHaveBeenCalled()
expect(cookiesSetMock).not.toHaveBeenCalled()
})
it('classifies 401 as stale-token', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse({}, 401))
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('stale-token')
expect(result.error).toMatch(/session expired/i)
})
it('classifies 403 as stale-token', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse({}, 403))
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('stale-token')
})
it('classifies 500 as server', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse({}, 500))
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('server')
expect(result.error).toMatch(/500/)
})
it('aborts a hung fetch after the timeout and classifies it as network', async () => {
netFetchMock.mockImplementation((_url: string, init: { signal: AbortSignal }) => {
return new Promise((_resolve, reject) => {
const abort = (): void => {
const error = new Error('The operation was aborted')
error.name = 'AbortError'
reject(error)
}
// The manual-cookie-header fallback receives the already-aborted signal.
if (init.signal.aborted) {
abort()
return
}
init.signal.addEventListener('abort', abort)
})
})
const promise = fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
await vi.advanceTimersByTimeAsync(15_000)
const result = await promise
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('network')
})
it('falls back to a manual Cookie header when the session cookie jar fetch throws', async () => {
netFetchMock
.mockRejectedValueOnce(new Error('cookie jar transport boom'))
.mockResolvedValueOnce(makeResponse(makeOkPayload(70)))
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('ok')
expect(result.session?.usedPercent).toBe(30)
expect(netFetchMock).toHaveBeenCalledTimes(2)
// First (session-cookie-jar) transport sends no Cookie header; the fallback does.
expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBeUndefined()
expect(netFetchMock.mock.calls[1][1].headers.Cookie).toContain('_token=')
})
it('returns ok with session window mapping remaining to usedPercent', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35)))
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('ok')
expect(result.provider).toBe('minimax')
expect(result.session?.usedPercent).toBe(65)
expect(result.session?.windowMinutes).toBe(300)
expect(result.weekly).toBeNull()
})
it('reports a fixed 5-hour session window regardless of API interval drift', async () => {
const startTime = 1_700_000_000_000
const endTime = startTime + 295 * 60_000
netFetchMock.mockResolvedValueOnce(
makeResponse({
base_resp: { status_code: 0, status_msg: 'ok' },
model_remains: [
{
model_name: 'general',
current_interval_remaining_percent: 60,
start_time: startTime,
end_time: endTime,
remains_time: endTime - startTime
}
]
})
)
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('ok')
expect(result.session?.windowMinutes).toBe(300)
expect(result.session?.resetsAt).toBe(endTime)
})
it('does not derive the windowMinutes label from the raw start/end interval', async () => {
const startTime = 1_700_000_000_000
const endTime = startTime + 4 * 60 * 60_000
netFetchMock.mockResolvedValueOnce(
makeResponse({
base_resp: { status_code: 0, status_msg: 'ok' },
model_remains: [
{
model_name: 'general',
current_interval_remaining_percent: 63,
start_time: startTime,
end_time: endTime,
remains_time: endTime - startTime
}
]
})
)
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('ok')
expect(result.session?.windowMinutes).toBe(300)
expect(result.session?.usedPercent).toBe(37)
expect(result.session?.resetsAt).toBe(endTime)
})
it('sets the cookie jar and sends browser-like request headers', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
expect(clearStorageDataMock).toHaveBeenCalledWith({
origin: 'https://platform.minimax.io',
storages: ['cookies']
})
expect(getCookieJarSetNames()).toEqual([
'_token',
'_twpid',
'minimax_group_id_v2',
'platform_cookie_consent'
])
expect(netFetchMock).toHaveBeenCalledTimes(1)
const [url, init] = netFetchMock.mock.calls[0]
expect(url).toBe(MINIMAX_URL)
expect(init.method).toBe('GET')
expect(init.headers.Cookie).toBeUndefined()
expect(init.headers['X-Group-Id']).toBe('12345')
expect(init.headers.Referer).toBe('https://platform.minimax.io/console/usage')
expect(init.headers.Accept).toMatch(/application\/json/)
expect(init.headers['Accept-Language']).toBe('en-US,en;q=0.9')
expect(init.headers['Sec-Fetch-Dest']).toBeUndefined()
expect(init.headers['Sec-Fetch-Mode']).toBeUndefined()
expect(init.headers['Sec-Fetch-Site']).toBeUndefined()
expect(init.headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
expect(init.headers['User-Agent']).not.toContain('orca-minimax-usage')
})
it('accepts quoted MiniMax cookie storage syntax', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
await fetchMiniMaxRateLimits({
cookie: '_token:"jwt-token" minimax_group_id_v2:"42"'
})
const [, init] = netFetchMock.mock.calls[0]
expect(getCookieJarSetNames()).toEqual(['_token', 'minimax_group_id_v2'])
expect(init.headers.Cookie).toBeUndefined()
expect(init.headers['X-Group-Id']).toBe('42')
})
it('preserves the complete browser Cookie header', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
const fullBrowserCookie = [
'platform_cookie_consent=3',
'_ga=analytics',
'_token=tok',
'_twpid=tw.1',
'ak_bmsc=ak',
'bm_sv=sv',
'bm_sz=sz',
'_abck=ab',
'minimax_group_id_v2=42',
'sensorsdata2015jssdkcross=analytics'
].join('; ')
await fetchMiniMaxRateLimits({
cookie: fullBrowserCookie
})
const [, init] = netFetchMock.mock.calls[0]
expect(getCookieJarSetNames()).toEqual([
'platform_cookie_consent',
'_ga',
'_token',
'_twpid',
'ak_bmsc',
'bm_sv',
'bm_sz',
'_abck',
'minimax_group_id_v2',
'sensorsdata2015jssdkcross'
])
expect(init.headers.Cookie).toBeUndefined()
})
it('prefers explicit groupId over cookie value', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: 'override-id' })
expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('override-id')
})
it('falls back to cookie minimax_group_id_v2 when groupId is empty', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: '' })
expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('12345')
})
it('surfaces base_resp status_code != 0 as usage-unavailable', async () => {
netFetchMock.mockResolvedValueOnce(
makeResponse({ base_resp: { status_code: 401, status_msg: 'unauth' } })
)
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('unauth')
})
it('classifies malformed MiniMax JSON responses as parse failures', async () => {
netFetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError('Unexpected token <')
}
} as unknown as Response)
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('parse')
expect(result.error).toContain('Unexpected token')
})
it('returns error when model_remains is empty', async () => {
netFetchMock.mockResolvedValueOnce(
makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
)
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toMatch(/configured model was not found/i)
})
it('returns error when configured model is not in response', async () => {
netFetchMock.mockResolvedValueOnce(
makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
)
const result = await fetchMiniMaxRateLimits({
cookie: FULL_COOKIE,
models: 'unrelated-model'
})
expect(result.status).toBe('error')
expect(result.error).toMatch(/configured model was not found/i)
})
it('falls back to the lone snapshot when no configured model matches', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(40)))
const result = await fetchMiniMaxRateLimits({
cookie: FULL_COOKIE,
models: 'unrelated-model'
})
expect(result.status).toBe('ok')
expect(result.session?.usedPercent).toBe(60)
})
it('selects the first configured model when multiple are listed', async () => {
const payload = makeOkPayload(40)
;(payload as { model_remains: unknown[] }).model_remains = [
{
model_name: 'unrelated',
current_interval_remaining_percent: 10,
start_time: Date.now() - 60_000,
end_time: Date.now() + 5 * 60 * 60 * 1000,
remains_time: 5 * 60 * 60 * 1000
},
{
model_name: 'general',
current_interval_remaining_percent: 40,
start_time: Date.now() - 60_000,
end_time: Date.now() + 5 * 60 * 60 * 1000,
remains_time: 5 * 60 * 60 * 1000
}
]
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
const result = await fetchMiniMaxRateLimits({
cookie: FULL_COOKIE,
models: 'general'
})
expect(result.status).toBe('ok')
expect(result.session?.usedPercent).toBe(60)
})
it('treats a blank model list as the default general model', async () => {
const payload = makeOkPayload(25)
;(payload as { model_remains: unknown[] }).model_remains = [
{
model_name: 'unrelated',
current_interval_remaining_percent: 10,
start_time: Date.now() - 60_000,
end_time: Date.now() + 5 * 60 * 60 * 1000,
remains_time: 5 * 60 * 60 * 1000
},
{
model_name: 'general',
current_interval_remaining_percent: 25,
start_time: Date.now() - 60_000,
end_time: Date.now() + 5 * 60 * 60 * 1000,
remains_time: 5 * 60 * 60 * 1000
}
]
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
const result = await fetchMiniMaxRateLimits({
cookie: FULL_COOKIE,
models: ' '
})
expect(result.status).toBe('ok')
expect(result.session?.usedPercent).toBe(75)
})
it('redacts _token in any error path that includes payload text', async () => {
const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } }
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(result.error).not.toContain('eyJh')
expect(result.error).not.toContain('minimax_group_id_v2=12345')
expect(result.error).toContain('[REDACTED]')
})
it('logs MiniMax failures with cookie names but without cookie values', async () => {
const warn = vi.mocked(console.warn)
netFetchMock.mockResolvedValueOnce(
makeResponse({ base_resp: { status_code: 7, status_msg: FULL_COOKIE } })
)
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
expect(warn).toHaveBeenCalledWith(
'[minimax] usage fetch failed',
expect.objectContaining({
baseRespStatusMsg: expect.not.stringContaining('eyJh'),
cookieNames: ['_token', '_twpid', 'minimax_group_id_v2', 'platform_cookie_consent']
})
)
})
})
describe('normalizeMiniMaxCookieHeader', () => {
it('preserves all cookie pairs from a browser Cookie header', () => {
const normalized = normalizeMiniMaxCookieHeader(
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
)
expect(normalized).toBe(
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
)
})
it('normalizes quoted MiniMax cookie storage syntax', () => {
expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
'_token=tok; minimax_group_id_v2=42'
)
})
it('accepts a copied Cookie header line', () => {
expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
'session=abc; other=xyz'
)
})
})
describe('extractMiniMaxCookieValue', () => {
it('returns the value for a given cookie name', () => {
expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345')
})
it('returns null when the name is absent', () => {
expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
})
})
describe('redactMiniMaxSecret', () => {
it('redacts _token values', () => {
expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]')
expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF')
})
it('redacts minimax_group_id_v2 values', () => {
expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
})
it('redacts MiniMax anti-bot cookie values', () => {
const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"')
expect(redacted).not.toContain('secret')
expect(redacted).toContain('ak_bmsc=[REDACTED]')
expect(redacted).toContain('bm_sv:[REDACTED]')
})
it('redacts Cookie: header lines', () => {
expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]')
})
})
+283
View File
@@ -0,0 +1,283 @@
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import {
extractMiniMaxCookieValue,
fetchMiniMaxWithManualCookieHeader,
fetchMiniMaxWithSessionCookieJar,
getUniqueMiniMaxCookieNames,
logMiniMaxFetchFailure,
makeMiniMaxRequestHeaders,
MINIMAX_USAGE_ENDPOINT,
normalizeMiniMaxCookieHeader,
redactMiniMaxSecret,
type MiniMaxFetchResponse
} from './minimax-request-context'
export {
extractMiniMaxCookieValue,
normalizeMiniMaxCookieHeader,
redactMiniMaxSecret
} from './minimax-request-context'
const API_TIMEOUT_MS = 15_000
type MiniMaxUsageItem = {
model_name?: unknown
current_interval_remaining_percent?: unknown
start_time?: unknown
end_time?: unknown
remains_time?: unknown
}
type MiniMaxUsageResponse = {
base_resp?: {
status_code?: unknown
status_msg?: unknown
}
model_remains?: MiniMaxUsageItem[]
}
type MiniMaxUsageSnapshot = {
modelName: string
window: RateLimitWindow
}
export type FetchMiniMaxRateLimitsOptions = {
cookie: string
groupId?: string | null
models?: string | readonly string[] | null
endpoint?: string
}
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)))
}
function makeUnavailable(error: string): ProviderRateLimits {
return {
provider: 'minimax',
session: null,
weekly: null,
updatedAt: Date.now(),
error,
status: 'unavailable',
usageMetadata: { failureKind: 'missing-credentials', source: 'web' }
}
}
function makeError(
error: string,
failureKind: NonNullable<ProviderRateLimits['usageMetadata']>['failureKind']
): ProviderRateLimits {
return {
provider: 'minimax',
session: null,
weekly: null,
updatedAt: Date.now(),
error,
status: 'error',
usageMetadata: { failureKind, source: 'web' }
}
}
function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] {
if (Array.isArray(models)) {
const parsed = models.map((model) => model.trim()).filter(Boolean)
return parsed.length > 0 ? parsed : ['general']
}
if (typeof models === 'string') {
const parsed = models
.split(',')
.map((model) => model.trim())
.filter(Boolean)
return parsed.length > 0 ? parsed : ['general']
}
return ['general']
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
// Why: MiniMax's API returns `end_time - start_time` that can drift below the
// 5-hour bucket (e.g. 4h or 295 min). The UI labels must reflect the contracted
// session — a fixed 5-hour window — so the status bar reads "5h" regardless of
// what the API reports. Mirrors how Codex always reports 300/10080 minutes.
const MINIMAX_SESSION_WINDOW_MINUTES = 300
function parseUsageItem(item: MiniMaxUsageItem): MiniMaxUsageSnapshot | null {
const modelName = typeof item.model_name === 'string' ? item.model_name : null
const remainingPercent = asNumber(item.current_interval_remaining_percent)
const startTime = asNumber(item.start_time)
const endTime = asNumber(item.end_time)
if (!modelName || remainingPercent === null || startTime === null || endTime === null) {
return null
}
return {
modelName,
window: {
usedPercent: clampPercent(100 - remainingPercent),
windowMinutes: MINIMAX_SESSION_WINDOW_MINUTES,
resetsAt: endTime,
resetDescription: null
}
}
}
function selectSnapshot(
snapshots: MiniMaxUsageSnapshot[],
preferredModels: string[]
): MiniMaxUsageSnapshot | null {
for (const model of preferredModels) {
const match = snapshots.find((snapshot) => snapshot.modelName === model)
if (match) {
return match
}
}
return snapshots.length === 1 ? snapshots[0] : null
}
async function fetchMiniMaxResponse(args: {
cookie: string
endpoint: string
groupId: string | null
signal: AbortSignal
}): Promise<MiniMaxFetchResponse> {
try {
return await fetchMiniMaxWithSessionCookieJar(args)
} catch (sessionFetchError) {
const message =
sessionFetchError instanceof Error ? sessionFetchError.message : String(sessionFetchError)
console.warn(
'[minimax] session cookie jar fetch failed; falling back to manual Cookie header',
{
error: redactMiniMaxSecret(message),
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
requestHeaderNames: Object.keys(makeMiniMaxRequestHeaders(args.groupId))
}
)
return await fetchMiniMaxWithManualCookieHeader(args)
}
}
function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits | null {
const { response } = fetchResult
if (response.status === 401 || response.status === 403) {
logMiniMaxFetchFailure({
transport: fetchResult.transport,
responseStatus: response.status,
cookieNames: fetchResult.cookieNames,
requestHeaderNames: fetchResult.requestHeaderNames
})
return makeError(
'MiniMax session expired. Replace the MiniMax cookie in Settings.',
'stale-token'
)
}
if (!response.ok) {
logMiniMaxFetchFailure({
transport: fetchResult.transport,
responseStatus: response.status,
cookieNames: fetchResult.cookieNames,
requestHeaderNames: fetchResult.requestHeaderNames
})
return makeError(`MiniMax usage fetch failed (${response.status})`, 'server')
}
return null
}
function handleMiniMaxPayloadError(
fetchResult: MiniMaxFetchResponse,
payload: MiniMaxUsageResponse
): ProviderRateLimits | null {
const statusCode = payload.base_resp?.status_code
if (statusCode === undefined || statusCode === 0) {
return null
}
logMiniMaxFetchFailure({
transport: fetchResult.transport,
responseStatus: fetchResult.response.status,
statusCode,
statusMsg: payload.base_resp?.status_msg,
cookieNames: fetchResult.cookieNames,
requestHeaderNames: fetchResult.requestHeaderNames
})
const message =
typeof payload.base_resp?.status_msg === 'string'
? payload.base_resp.status_msg
: 'MiniMax returned an error'
return makeError(redactMiniMaxSecret(message), 'usage-unavailable')
}
export async function fetchMiniMaxRateLimits(
options: FetchMiniMaxRateLimitsOptions
): Promise<ProviderRateLimits> {
const rawCookie = options.cookie.trim()
if (!rawCookie) {
return makeUnavailable('MiniMax session cookie not configured')
}
const cookie = normalizeMiniMaxCookieHeader(rawCookie)
if (!extractMiniMaxCookieValue(cookie, '_token')) {
return makeError(
'MiniMax auth cookie not found — paste a Cookie header with _token',
'missing-credentials'
)
}
const groupId =
options.groupId?.trim() || extractMiniMaxCookieValue(cookie, 'minimax_group_id_v2')
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
try {
const fetchResult = await fetchMiniMaxResponse({
cookie,
endpoint: options.endpoint ?? MINIMAX_USAGE_ENDPOINT,
groupId,
signal: controller.signal
})
const httpError = handleMiniMaxHttpError(fetchResult)
if (httpError) {
return httpError
}
let payload: MiniMaxUsageResponse
try {
payload = (await fetchResult.response.json()) as MiniMaxUsageResponse
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid MiniMax usage response'
return makeError(redactMiniMaxSecret(message), 'parse')
}
const payloadError = handleMiniMaxPayloadError(fetchResult, payload)
if (payloadError) {
return payloadError
}
const snapshots = (payload.model_remains ?? [])
.map(parseUsageItem)
.filter((snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== null)
const selected = selectSnapshot(snapshots, parseModels(options.models))
if (!selected) {
return makeError(
'MiniMax usage data for the configured model was not found',
'usage-unavailable'
)
}
return {
provider: 'minimax',
session: selected.window,
weekly: null,
updatedAt: Date.now(),
error: null,
status: 'ok',
usageMetadata: { source: 'web' }
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error'
return makeError(redactMiniMaxSecret(message), 'network')
} finally {
clearTimeout(timeout)
}
}
@@ -0,0 +1,453 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
() => {
const netFetchMock = vi.fn()
const cookiesSetMock = vi.fn(() => Promise.resolve())
const clearStorageDataMock = vi.fn(() => Promise.resolve())
const sessionFromPartitionMock = vi.fn(() => ({
clearStorageData: clearStorageDataMock,
cookies: { set: cookiesSetMock },
fetch: netFetchMock
}))
return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
}
)
vi.mock('electron', () => ({
net: { fetch: netFetchMock },
session: { fromPartition: sessionFromPartitionMock }
}))
import {
clearMiniMaxSessionCookieJar,
extractMiniMaxCookieValue,
fetchMiniMaxWithManualCookieHeader,
fetchMiniMaxWithSessionCookieJar,
getUniqueMiniMaxCookieNames,
logMiniMaxFetchFailure,
makeMiniMaxRequestHeaders,
MINIMAX_USAGE_ENDPOINT,
normalizeMiniMaxCookieHeader,
redactMiniMaxSecret
} from './minimax-request-context'
const FULL_COOKIE =
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
describe('normalizeMiniMaxCookieHeader', () => {
it('preserves all cookie pairs from a browser Cookie header', () => {
const normalized = normalizeMiniMaxCookieHeader(
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
)
expect(normalized).toBe(
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
)
})
it('normalizes quoted MiniMax cookie storage syntax', () => {
expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
'_token=tok; minimax_group_id_v2=42'
)
})
it('accepts a copied Cookie header line with prefix', () => {
expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
'session=abc; other=xyz'
)
})
it('merges header and quoted forms without dropping any pair', () => {
const mixed = '_token=header-quoted _twpid:"q-1"; minimax_group_id_v2=42'
const normalized = normalizeMiniMaxCookieHeader(mixed)
expect(normalized).toContain('_token=header-quoted')
expect(normalized).toContain('_twpid=q-1')
expect(normalized).toContain('minimax_group_id_v2=42')
})
})
describe('extractMiniMaxCookieValue', () => {
it('returns the value for a known name', () => {
expect(extractMiniMaxCookieValue(FULL_COOKIE, '_token')).toBe('eyJh.eyJ.payload')
})
it('returns null when the name is absent', () => {
expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
})
it('handles Chromium quoted syntax', () => {
expect(
extractMiniMaxCookieValue('_token:"jwt" minimax_group_id_v2:"42"', 'minimax_group_id_v2')
).toBe('42')
})
})
describe('getUniqueMiniMaxCookieNames', () => {
it('deduplicates repeated names', () => {
const names = getUniqueMiniMaxCookieNames(
'_token=a; _token=b; minimax_group_id_v2=42; minimax_group_id_v2=99'
)
expect(names).toEqual(['_token', 'minimax_group_id_v2'])
})
it('returns names from a quoted-only Cookie storage export', () => {
const names = getUniqueMiniMaxCookieNames(
'_token:"jwt" minimax_group_id_v2:"42" platform_cookie_consent:"3"'
)
expect(names).toEqual(
expect.arrayContaining(['_token', 'minimax_group_id_v2', 'platform_cookie_consent'])
)
})
})
describe('redactMiniMaxSecret', () => {
it('redacts _token values in header and quoted syntax', () => {
const redacted = redactMiniMaxSecret('cookie _token=eyJhABCDEF and _token:"x.y"')
expect(redacted).toContain('_token=[REDACTED]')
expect(redacted).not.toContain('eyJhABCDEF')
expect(redacted).not.toContain('x.y')
})
it('redacts quoted values with whitespace around the colon', () => {
const redacted = redactMiniMaxSecret('_token : "spaced.secret"')
expect(redacted).not.toContain('spaced.secret')
expect(redacted).toContain('_token:[REDACTED]')
})
it('redacts minimax_group_id_v2', () => {
expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
})
it('redacts MiniMax anti-bot cookie values', () => {
const redacted = redactMiniMaxSecret('ak_bmsc=zzzsecret bm_sv:"yyyvalue" _abck="xxxdata"')
expect(redacted).not.toContain('zzzsecret')
expect(redacted).not.toContain('yyyvalue')
expect(redacted).not.toContain('xxxdata')
expect(redacted).toContain('ak_bmsc=[REDACTED]')
expect(redacted).toContain('bm_sv:[REDACTED]')
expect(redacted).toContain('_abck=[REDACTED]')
})
it('redacts full Cookie: header lines', () => {
const redacted = redactMiniMaxSecret('Header line\nCookie: _token=secret\nFooter')
expect(redacted).toContain('Cookie: [REDACTED]')
expect(redacted).not.toContain('Cookie: _token=secret')
})
})
describe('makeMiniMaxRequestHeaders', () => {
it('always includes browser-like Accept, Accept-Language, Referer, and User-Agent', () => {
const headers = makeMiniMaxRequestHeaders(null)
expect(headers.Accept).toMatch(/application\/json/)
expect(headers['Accept-Language']).toBe('en-US,en;q=0.9')
expect(headers.Referer).toBe('https://platform.minimax.io/console/usage')
expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
expect(headers['User-Agent']).not.toContain('orca-minimax-usage')
})
it('omits X-Group-Id when groupId is null', () => {
const headers = makeMiniMaxRequestHeaders(null)
expect(headers['X-Group-Id']).toBeUndefined()
})
it('omits X-Group-Id when groupId is empty string', () => {
const headers = makeMiniMaxRequestHeaders('')
expect(headers['X-Group-Id']).toBeUndefined()
})
it('includes X-Group-Id when groupId is provided', () => {
const headers = makeMiniMaxRequestHeaders('2034972027806299092')
expect(headers['X-Group-Id']).toBe('2034972027806299092')
})
})
describe('fetchMiniMaxWithSessionCookieJar', () => {
beforeEach(() => {
clearStorageDataMock.mockClear()
cookiesSetMock.mockClear()
netFetchMock.mockReset()
sessionFromPartitionMock.mockClear()
sessionFromPartitionMock.mockImplementation(() => ({
clearStorageData: clearStorageDataMock,
cookies: { set: cookiesSetMock },
fetch: netFetchMock
}))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('uses a dedicated MiniMax partition and clears cookies before and after fetching', async () => {
netFetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
})
const controller = new AbortController()
await fetchMiniMaxWithSessionCookieJar({
cookie: FULL_COOKIE,
endpoint: MINIMAX_USAGE_ENDPOINT,
groupId: '12345',
signal: controller.signal
})
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, {
origin: 'https://platform.minimax.io',
storages: ['cookies']
})
expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, {
origin: 'https://platform.minimax.io',
storages: ['cookies']
})
})
it('attempts the final session cleanup when the pre-fetch clear rejects', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
clearStorageDataMock
.mockRejectedValueOnce(new Error('pre-clear boom'))
.mockResolvedValueOnce(undefined)
const controller = new AbortController()
await expect(
fetchMiniMaxWithSessionCookieJar({
cookie: FULL_COOKIE,
endpoint: MINIMAX_USAGE_ENDPOINT,
groupId: '12345',
signal: controller.signal
})
).rejects.toThrow('pre-clear boom')
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, {
origin: 'https://platform.minimax.io',
storages: ['cookies']
})
expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, {
origin: 'https://platform.minimax.io',
storages: ['cookies']
})
expect(cookiesSetMock).not.toHaveBeenCalled()
expect(netFetchMock).not.toHaveBeenCalled()
expect(warn).not.toHaveBeenCalled()
})
it('clears the dedicated MiniMax partition on demand', async () => {
await clearMiniMaxSessionCookieJar()
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
expect(clearStorageDataMock).toHaveBeenCalledWith({
origin: 'https://platform.minimax.io',
storages: ['cookies']
})
})
it('sets every cookie pair onto the session jar with secure + path /', async () => {
netFetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
})
const controller = new AbortController()
await fetchMiniMaxWithSessionCookieJar({
cookie: '_token=tok; ak_bmsc=ak; minimax_group_id_v2=42',
endpoint: MINIMAX_USAGE_ENDPOINT,
groupId: null,
signal: controller.signal
})
expect(cookiesSetMock).toHaveBeenCalledTimes(3)
const setDetails = cookiesSetMock.mock.calls.map((call) => {
const [details] = call as unknown as [
{ name: string; value: string; secure: boolean; path: string }
]
return details
})
expect(setDetails).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: '_token', value: 'tok', secure: true, path: '/' }),
expect.objectContaining({ name: 'ak_bmsc', value: 'ak', secure: true, path: '/' }),
expect.objectContaining({
name: 'minimax_group_id_v2',
value: '42',
secure: true,
path: '/'
})
])
)
})
it('reports the transport name as session-cookie-jar on success', async () => {
netFetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
})
const controller = new AbortController()
const result = await fetchMiniMaxWithSessionCookieJar({
cookie: FULL_COOKIE,
endpoint: MINIMAX_USAGE_ENDPOINT,
groupId: '12345',
signal: controller.signal
})
expect(result.transport).toBe('session-cookie-jar')
expect(result.cookieNames).toEqual([
'_token',
'_twpid',
'minimax_group_id_v2',
'platform_cookie_consent'
])
expect(result.requestHeaderNames).toContain('X-Group-Id')
})
})
describe('fetchMiniMaxWithManualCookieHeader', () => {
beforeEach(() => {
clearStorageDataMock.mockClear()
cookiesSetMock.mockClear()
netFetchMock.mockReset()
sessionFromPartitionMock.mockClear()
sessionFromPartitionMock.mockImplementation(() => ({
clearStorageData: clearStorageDataMock,
cookies: { set: cookiesSetMock },
fetch: netFetchMock
}))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('attaches the full Cookie header and X-Group-Id via the dedicated partition fetch', async () => {
netFetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
})
const controller = new AbortController()
const result = await fetchMiniMaxWithManualCookieHeader({
cookie: FULL_COOKIE,
endpoint: MINIMAX_USAGE_ENDPOINT,
groupId: '12345',
signal: controller.signal
})
expect(result.transport).toBe('manual-cookie-header')
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
expect(cookiesSetMock).not.toHaveBeenCalled()
expect(netFetchMock).toHaveBeenCalledTimes(1)
const [url, init] = netFetchMock.mock.calls[0]
expect(url).toBe(MINIMAX_USAGE_ENDPOINT)
expect(init.method).toBe('GET')
expect(init.headers.Cookie).toBe(FULL_COOKIE)
expect(init.headers['X-Group-Id']).toBe('12345')
})
it('omits X-Group-Id when groupId is null', async () => {
netFetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
})
const controller = new AbortController()
await fetchMiniMaxWithManualCookieHeader({
cookie: FULL_COOKIE,
endpoint: MINIMAX_USAGE_ENDPOINT,
groupId: null,
signal: controller.signal
})
const [, init] = netFetchMock.mock.calls[0]
expect(init.headers['X-Group-Id']).toBeUndefined()
})
it('normalizes copied Cookie prefix and quoted syntax before sending manual header', async () => {
netFetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
})
const controller = new AbortController()
await fetchMiniMaxWithManualCookieHeader({
cookie: 'Cookie: _token=tok; minimax_group_id_v2=42; _twpid:"tw"',
endpoint: MINIMAX_USAGE_ENDPOINT,
groupId: null,
signal: controller.signal
})
const [, init] = netFetchMock.mock.calls[0]
expect(init.headers.Cookie).toBe('_token=tok; minimax_group_id_v2=42; _twpid=tw')
})
it('attempts the final cleanup when the manual fallback pre-fetch clear rejects', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
clearStorageDataMock
.mockRejectedValueOnce(new Error('manual pre-clear boom'))
.mockResolvedValueOnce(undefined)
const controller = new AbortController()
await expect(
fetchMiniMaxWithManualCookieHeader({
cookie: FULL_COOKIE,
endpoint: MINIMAX_USAGE_ENDPOINT,
groupId: '12345',
signal: controller.signal
})
).rejects.toThrow('manual pre-clear boom')
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
expect(netFetchMock).not.toHaveBeenCalled()
expect(warn).not.toHaveBeenCalled()
})
})
describe('logMiniMaxFetchFailure', () => {
let warn: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('logs structured fields without leaking cookie values', () => {
logMiniMaxFetchFailure({
transport: 'session-cookie-jar',
responseStatus: 200,
statusCode: 7,
statusMsg: '_token=eyJhABCDEF ak_bmsc=secretvalue',
cookieNames: ['_token', 'ak_bmsc'],
requestHeaderNames: ['Accept', 'X-Group-Id']
})
expect(warn).toHaveBeenCalledWith(
'[minimax] usage fetch failed',
expect.objectContaining({
transport: 'session-cookie-jar',
responseStatus: 200,
baseRespStatusCode: 7,
baseRespStatusMsg: expect.not.stringContaining('eyJhABCDEF'),
cookieNames: ['_token', 'ak_bmsc'],
requestHeaderNames: ['Accept', 'X-Group-Id']
})
)
})
it('logs without redacting when statusMsg is not a string', () => {
logMiniMaxFetchFailure({
transport: 'manual-cookie-header',
responseStatus: 401,
statusCode: undefined,
statusMsg: undefined,
cookieNames: ['_token'],
requestHeaderNames: ['Accept']
})
expect(warn).toHaveBeenCalledWith(
'[minimax] usage fetch failed',
expect.objectContaining({
transport: 'manual-cookie-header',
responseStatus: 401,
baseRespStatusCode: undefined,
baseRespStatusMsg: undefined
})
)
})
})
@@ -0,0 +1,203 @@
import { session, type Session } from 'electron'
export const MINIMAX_USAGE_ENDPOINT =
'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
const MINIMAX_ORIGIN = 'https://platform.minimax.io'
const MINIMAX_REFERER = 'https://platform.minimax.io/console/usage'
const MINIMAX_SESSION_PARTITION = 'orca-minimax-rate-limit-fetch'
const SENSITIVE_COOKIE_NAMES = new Set([
'_token',
'_twpid',
'_abck',
'ak_bmsc',
'bm_mi',
'bm_sv',
'bm_sz',
'minimax_group_id_v2'
])
export type MiniMaxFetchTransport = 'session-cookie-jar' | 'manual-cookie-header'
export type MiniMaxFetchResponse = {
response: Response
requestHeaderNames: string[]
cookieNames: string[]
transport: MiniMaxFetchTransport
}
// Why: MiniMax's usage endpoint rejects non-browser clients, so we send a real
// per-platform Firefox UA instead of a custom agent string. Don't "clean up".
function getMiniMaxBrowserUserAgent(): string {
if (process.platform === 'win32') {
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0'
}
if (process.platform === 'darwin') {
return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0'
}
return 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0'
}
function parseCookiePairs(cookie: string): { name: string; value: string }[] {
const headerPairs = cookie
.split(';')
.map((part) => part.trim())
.map((part) => {
const normalizedPart = part.replace(/^Cookie:\s*/i, '')
const eq = normalizedPart.indexOf('=')
if (eq < 0) {
return null
}
return {
name: normalizedPart.slice(0, eq).trim(),
value: normalizedPart.slice(eq + 1).trim()
}
})
.filter((pair): pair is { name: string; value: string } => Boolean(pair?.name && pair.value))
// Why: Chromium cookie storage exports are often copied as `name:"value"`,
// not as an HTTP `Cookie` header. Accept both formats to avoid credential UX traps.
const quotedCookiePairPattern = /(?:^|[;\s])([A-Za-z0-9_.-]+)\s*:\s*["']([^"']+)["']/g
const quotedPairs = [...cookie.matchAll(quotedCookiePairPattern)]
.map((match) => {
const [, name = '', value = ''] = match
return { name: name.trim(), value: value.trim() }
})
.filter((pair) => pair.name && pair.value)
return [...headerPairs, ...quotedPairs]
}
export function extractMiniMaxCookieValue(cookie: string, name: string): string | null {
return parseCookiePairs(cookie).find((pair) => pair.name === name)?.value ?? null
}
export function normalizeMiniMaxCookieHeader(cookie: string): string {
return parseCookiePairs(cookie)
.map((pair) => `${pair.name}=${pair.value}`)
.join('; ')
}
export function getUniqueMiniMaxCookieNames(cookie: string): string[] {
return [...new Set(parseCookiePairs(cookie).map((pair) => pair.name))]
}
export function redactMiniMaxSecret(value: string): string {
let redacted = value.replace(/Cookie:\s*[^\n\r]+/gi, 'Cookie: [REDACTED]')
for (const name of SENSITIVE_COOKIE_NAMES) {
redacted = redacted
.replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`)
// Match parseCookiePairs' `\s*:\s*` tolerance so `name : "secret"` is redacted too.
.replace(new RegExp(`${name}\\s*:\\s*["'][^"']+["']`, 'g'), `${name}:[REDACTED]`)
}
return redacted
}
export function makeMiniMaxRequestHeaders(groupId: string | null): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
Referer: MINIMAX_REFERER,
'User-Agent': getMiniMaxBrowserUserAgent()
}
if (groupId) {
headers['X-Group-Id'] = groupId
}
return headers
}
async function clearMiniMaxSessionCookieJarForSession(miniMaxSession: Session): Promise<void> {
await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] })
}
export async function clearMiniMaxSessionCookieJar(): Promise<void> {
await clearMiniMaxSessionCookieJarForSession(session.fromPartition(MINIMAX_SESSION_PARTITION))
}
export async function fetchMiniMaxWithSessionCookieJar(args: {
cookie: string
endpoint: string
groupId: string | null
signal: AbortSignal
}): Promise<MiniMaxFetchResponse> {
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
const cookiePairs = parseCookiePairs(args.cookie)
try {
await clearMiniMaxSessionCookieJarForSession(miniMaxSession)
await Promise.all(
cookiePairs.map((pair) =>
miniMaxSession.cookies.set({
url: MINIMAX_ORIGIN,
name: pair.name,
value: pair.value,
secure: true,
path: '/'
})
)
)
const headers = makeMiniMaxRequestHeaders(args.groupId)
return {
response: await miniMaxSession.fetch(args.endpoint, {
method: 'GET',
headers,
signal: args.signal
}),
requestHeaderNames: Object.keys(headers),
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
transport: 'session-cookie-jar'
}
} finally {
await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => {
console.warn('[minimax] failed to clear session cookie jar after fetch', error)
})
}
}
export async function fetchMiniMaxWithManualCookieHeader(args: {
cookie: string
endpoint: string
groupId: string | null
signal: AbortSignal
}): Promise<MiniMaxFetchResponse> {
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
try {
await clearMiniMaxSessionCookieJarForSession(miniMaxSession)
const headers = {
...makeMiniMaxRequestHeaders(args.groupId),
Cookie: normalizeMiniMaxCookieHeader(args.cookie)
}
return {
response: await miniMaxSession.fetch(args.endpoint, {
method: 'GET',
headers,
signal: args.signal
}),
requestHeaderNames: Object.keys(headers),
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
transport: 'manual-cookie-header'
}
} finally {
await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => {
console.warn('[minimax] failed to clear session cookie jar after fetch', error)
})
}
}
export function logMiniMaxFetchFailure(details: {
transport: MiniMaxFetchTransport
responseStatus?: number
statusCode?: unknown
statusMsg?: unknown
cookieNames: string[]
requestHeaderNames: string[]
}): void {
console.warn('[minimax] usage fetch failed', {
transport: details.transport,
responseStatus: details.responseStatus,
baseRespStatusCode: details.statusCode,
baseRespStatusMsg:
typeof details.statusMsg === 'string'
? redactMiniMaxSecret(details.statusMsg)
: details.statusMsg,
cookieNames: details.cookieNames,
requestHeaderNames: details.requestHeaderNames
})
}
+302 -39
View File
@@ -9,7 +9,9 @@ import { RateLimitService } from './service'
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
vi.mock('./claude-fetcher', () => ({
fetchClaudeRateLimits: vi.fn(),
@@ -28,6 +30,14 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
@@ -42,7 +52,7 @@ function deferred<T>(): Deferred<T> {
}
function okProvider(
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
usedPercent: number,
updatedAt = Date.now()
): ProviderRateLimits {
@@ -62,7 +72,7 @@ function okProvider(
}
function errorProvider(
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
message: string
): ProviderRateLimits {
return {
@@ -116,6 +126,8 @@ describe('RateLimitService', () => {
vi.clearAllMocks()
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now()))
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
})
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
@@ -333,6 +345,110 @@ describe('RateLimitService', () => {
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
})
it('aborts the active fetch cycle and clears queued refreshes on stop', async () => {
const service = new RateLimitService()
const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {}
vi.mocked(fetchClaudeRateLimits).mockImplementation(
(options) =>
new Promise((resolve) => {
capturedSignals.claude = options?.signal
options?.signal?.addEventListener(
'abort',
() => resolve(errorProvider('claude', 'aborted')),
{ once: true }
)
})
)
vi.mocked(fetchCodexRateLimits).mockImplementation(
(options) =>
new Promise((resolve) => {
capturedSignals.codex = options?.signal
options?.signal?.addEventListener(
'abort',
() => resolve(errorProvider('codex', 'aborted')),
{ once: true }
)
})
)
const activeFetch = serviceInternals(service).fetchAll()
await Promise.resolve()
await Promise.resolve()
const queuedRefresh = service.refresh()
await Promise.resolve()
service.stop()
expect(capturedSignals.claude?.aborted).toBe(true)
expect(capturedSignals.codex?.aborted).toBe(true)
await queuedRefresh
await activeFetch
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
})
it('aborts inactive Claude preview fetches on stop', async () => {
const service = new RateLimitService()
const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
const capturedSignals: { claude?: AbortSignal } = {}
service.setInactiveClaudeAccountsResolver(() => [account])
vi.mocked(fetchManagedAccountUsage).mockImplementation(
(_account, options) =>
new Promise((resolve) => {
capturedSignals.claude = options?.signal
options?.signal?.addEventListener(
'abort',
() => resolve(errorProvider('claude', 'aborted')),
{ once: true }
)
})
)
const previewFetch = service.fetchInactiveClaudeAccountsOnOpen()
await Promise.resolve()
service.stop()
expect(capturedSignals.claude?.aborted).toBe(true)
await previewFetch
expect(service.getState().inactiveClaudeAccounts).toEqual([])
})
it('aborts inactive Codex preview fetches on stop', async () => {
const service = new RateLimitService()
const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' }
const capturedSignals: { codex?: AbortSignal } = {}
service.setInactiveCodexAccountsResolver(() => [account])
vi.mocked(fetchCodexRateLimits).mockImplementation(
(options) =>
new Promise((resolve) => {
capturedSignals.codex = options?.signal
options?.signal?.addEventListener(
'abort',
() => resolve(errorProvider('codex', 'aborted')),
{ once: true }
)
})
)
const previewFetch = service.fetchInactiveCodexAccountsOnOpen()
await Promise.resolve()
service.stop()
expect(capturedSignals.codex?.aborted).toBe(true)
await previewFetch
expect(service.getState().inactiveCodexAccounts).toEqual([])
})
it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => {
const service = new RateLimitService()
service.setOpenCodeGoConfigResolver(() => ({
@@ -351,11 +467,14 @@ describe('RateLimitService', () => {
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: undefined,
allowPtyFallback: false,
allowUsagePanelSupplement: true
})
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
expect.objectContaining({
authPreparation: undefined,
allowPtyFallback: false,
allowUsagePanelSupplement: true,
signal: expect.any(AbortSignal)
})
)
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true)
@@ -453,16 +572,19 @@ describe('RateLimitService', () => {
await service.refresh()
expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: expect.objectContaining({
runtime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxConfigDir: '/home/jin/.claude',
stripAuthEnv: true
}),
allowPtyFallback: true,
allowUsagePanelSupplement: true
})
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
expect.objectContaining({
authPreparation: expect.objectContaining({
runtime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxConfigDir: '/home/jin/.claude',
stripAuthEnv: true
}),
allowPtyFallback: true,
allowUsagePanelSupplement: true,
signal: expect.any(AbortSignal)
})
)
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
@@ -483,11 +605,14 @@ describe('RateLimitService', () => {
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: expect.objectContaining({ provenance: 'system' }),
allowPtyFallback: false,
allowUsagePanelSupplement: true
})
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
expect.objectContaining({
authPreparation: expect.objectContaining({ provenance: 'system' }),
allowPtyFallback: false,
allowUsagePanelSupplement: true,
signal: expect.any(AbortSignal)
})
)
})
it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => {
@@ -498,11 +623,14 @@ describe('RateLimitService', () => {
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: undefined,
allowPtyFallback: false,
allowUsagePanelSupplement: true
})
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
expect.objectContaining({
authPreparation: undefined,
allowPtyFallback: false,
allowUsagePanelSupplement: true,
signal: expect.any(AbortSignal)
})
)
})
it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => {
@@ -523,11 +651,14 @@ describe('RateLimitService', () => {
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
allowPtyFallback: false,
allowUsagePanelSupplement: true
})
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
expect.objectContaining({
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
allowPtyFallback: false,
allowUsagePanelSupplement: true,
signal: expect.any(AbortSignal)
})
)
})
it('does not cache host Codex usage under an outgoing WSL account', async () => {
@@ -604,10 +735,13 @@ describe('RateLimitService', () => {
await service.fetchInactiveCodexAccountsOnOpen()
expect(fetchCodexRateLimits).toHaveBeenCalledWith({
codexHomePath: wslCodexHome,
allowPtyFallback: false
})
expect(fetchCodexRateLimits).toHaveBeenCalledWith(
expect.objectContaining({
codexHomePath: wslCodexHome,
allowPtyFallback: false,
signal: expect.any(AbortSignal)
})
)
expect(service.getState().inactiveCodexAccounts).toEqual([
{
accountId: 'account-1',
@@ -629,9 +763,13 @@ describe('RateLimitService', () => {
await service.fetchInactiveClaudeAccountsOnOpen()
expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, {
allowUsagePanelSupplement: true
})
expect(fetchManagedAccountUsage).toHaveBeenCalledWith(
account,
expect.objectContaining({
allowUsagePanelSupplement: true,
signal: expect.any(AbortSignal)
})
)
})
it('does not start overlapping inactive Codex preview fetches', async () => {
@@ -930,4 +1068,129 @@ describe('RateLimitService', () => {
}
])
})
it('fetches MiniMax alongside other providers when a config resolver is set', async () => {
const service = new RateLimitService()
service.setMiniMaxConfigResolver(() => ({
sessionCookie: '_token=abc; minimax_group_id_v2=42',
groupId: '',
models: 'general'
}))
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 50, Date.now()))
await service.refresh()
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1)
expect(fetchMiniMaxRateLimits).toHaveBeenCalledWith({
cookie: '_token=abc; minimax_group_id_v2=42',
groupId: '',
models: 'general'
})
const state = service.getState()
expect(state.minimax?.status).toBe('ok')
expect(state.minimax?.session?.usedPercent).toBe(50)
expect(state.minimaxCookieConfigured).toBe(true)
})
it('reports minimaxCookieConfigured from the cookie store even without a resolver', () => {
const service = new RateLimitService()
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
expect(service.getState().minimaxCookieConfigured).toBe(true)
})
it('discards the previous MiniMax snapshot when its config hash changes', async () => {
const service = new RateLimitService()
let models = 'general'
service.setMiniMaxConfigResolver(() => ({
sessionCookie: '_token=abc',
groupId: '',
models
}))
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
vi.mocked(fetchMiniMaxRateLimits)
.mockResolvedValueOnce(okProvider('minimax', 40, Date.now()))
.mockResolvedValueOnce(okProvider('minimax', 10, Date.now()))
await service.refresh()
expect(service.getState().minimax?.session?.usedPercent).toBe(40)
models = 'premium'
await service.refresh()
const state = service.getState()
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
expect(state.minimax?.session?.usedPercent).toBe(10)
})
it('does not apply an in-flight MiniMax result after credential invalidation', async () => {
const service = new RateLimitService()
const firstMiniMax = deferred<ProviderRateLimits>()
const secondMiniMax = deferred<ProviderRateLimits>()
service.setMiniMaxConfigResolver(() => ({
sessionCookie: '_token=abc',
groupId: '',
models: 'general'
}))
vi.mocked(fetchMiniMaxRateLimits)
.mockImplementationOnce(() => firstMiniMax.promise)
.mockImplementationOnce(() => secondMiniMax.promise)
const firstRefresh = service.refresh()
await Promise.resolve()
service.invalidateMiniMaxCredentialState()
const queuedRefresh = service.refresh()
await Promise.resolve()
firstMiniMax.resolve(okProvider('minimax', 50, Date.now()))
await Promise.resolve()
await Promise.resolve()
expect(service.getState().minimax?.status).toBe('fetching')
expect(service.getState().minimax?.session).toBeNull()
secondMiniMax.resolve(okProvider('minimax', 10, Date.now()))
await firstRefresh
await queuedRefresh
const state = service.getState()
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
expect(state.minimax?.session?.usedPercent).toBe(10)
})
it('isolates MiniMax failures from other providers', async () => {
const service = new RateLimitService()
service.setMiniMaxConfigResolver(() => ({
sessionCookie: '_token=abc',
groupId: '',
models: 'general'
}))
vi.mocked(fetchMiniMaxRateLimits).mockRejectedValueOnce(new Error('minimax down'))
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
await service.refresh()
const state = service.getState()
expect(state.minimax?.status).toBe('error')
expect(state.minimax?.error).toBe('minimax down')
expect(state.claude?.status).toBe('ok')
})
it('isolates MiniMax config resolver failures from other providers', async () => {
const service = new RateLimitService()
service.setMiniMaxConfigResolver(() => {
throw new Error('MiniMax session cookie could not be decrypted')
})
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
await service.refresh()
const state = service.getState()
expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled()
expect(state.minimax?.status).toBe('error')
expect(state.minimax?.error).toBe('MiniMax session cookie could not be decrypted')
expect(state.claude?.status).toBe('ok')
})
})
+361 -91
View File
@@ -21,6 +21,8 @@ import {
} from '../claude-accounts/runtime-selection'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import {
normalizeCodexAccountSelectionTarget,
@@ -43,6 +45,17 @@ type OpenCodeGoRateLimitConfig = {
workspaceIdOverride: string
}
type MiniMaxRateLimitConfig = {
sessionCookie: string
groupId: string
models: string
}
type MiniMaxResolvedConfig = {
config: MiniMaxRateLimitConfig
error: string | null
}
type GeminiCliOAuthEnabledResolver = () => boolean
// Why: Claude's subscription usage endpoint has a tight request budget. Quota
@@ -64,6 +77,7 @@ type InternalRateLimitState = {
gemini: ProviderRateLimits | null
opencodeGo: ProviderRateLimits | null
kimi: ProviderRateLimits | null
minimax: ProviderRateLimits | null
}
function normalizePollingInterval(ms: number): number {
@@ -85,13 +99,18 @@ function isSystemDefaultClaudeAuth(
return provenance === 'system' || Boolean(provenance?.endsWith(':system'))
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export class RateLimitService {
private state: InternalRateLimitState = {
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
kimi: null
kimi: null,
minimax: null
}
private pollInterval: number = DEFAULT_POLL_MS
private timer: ReturnType<typeof setInterval> | null = null
@@ -103,11 +122,14 @@ 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
private opencodeFetchGeneration = 0
private minimaxFetchGeneration = 0
private lastOpencodeConfigHash = ''
private lastMiniMaxConfigHash = ''
private codexHomePathResolver: CodexHomePathResolver | null = null
private codexFetchTarget: NormalizedCodexAccountSelectionTarget = {
runtime: 'host',
@@ -119,6 +141,7 @@ export class RateLimitService {
wslDistro: null
}
private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null
private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null
@@ -162,6 +185,10 @@ export class RateLimitService {
this.openCodeGoConfigResolver = resolver
}
setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void {
this.miniMaxConfigResolver = resolver
}
setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void {
this.geminiCliOAuthEnabledResolver = resolver
}
@@ -221,6 +248,11 @@ export class RateLimitService {
}
stop(): void {
this.abortActiveFetchCycle()
this.clearQueuedFetches()
this.inactiveClaudeFetching.clear()
this.inactiveCodexFetching.clear()
this.resolveAndClearFetchIdleWaiters()
this.stopTimer()
this.clearDeferredStartupRefresh()
this.detachWindowListeners?.()
@@ -233,6 +265,10 @@ export class RateLimitService {
this.pruneInactiveCodexState()
return {
...this.state,
// Why: the cookie lives in the file system, not GlobalSettings. Surface
// its presence on the pushed state so the renderer keeps the MiniMax
// bar visible across reloads and between snapshot refreshes.
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
claudeTarget: this.claudeFetchTarget,
codexTarget: this.codexFetchTarget,
inactiveClaudeAccounts: this.buildInactiveArray(
@@ -255,6 +291,16 @@ export class RateLimitService {
return this.getState()
}
invalidateMiniMaxCredentialState(): void {
this.minimaxFetchGeneration += 1
// Why: saving or forgetting the browser cookie can race an in-flight usage
// fetch; clear the visible snapshot before any old-cookie result returns.
this.updateState({
...this.state,
minimax: this.withFetchingStatus(null, 'minimax')
})
}
async refreshForCodexAccountChange(
outgoingAccountId?: string | null,
target?: CodexAccountSelectionTarget
@@ -369,30 +415,18 @@ export class RateLimitService {
return
}
const fetchGeneration = this.inactiveClaudeAccountsGeneration
const controller = this.beginFetchCycle()
const signal = controller.signal
for (const account of accounts) {
this.inactiveClaudeFetching.add(account.id)
}
this.pushToRenderer()
for (const account of accounts) {
if (
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
this.inactiveClaudeFetching.delete(account.id)
if (!this.isCurrentInactiveClaudeAccount(account.id)) {
this.inactiveClaudeCache.delete(account.id)
}
this.pushToRenderer()
continue
}
try {
const fresh = await fetchManagedAccountUsage(account, {
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
networkProxySettings: this.networkProxySettingsResolver?.()
})
try {
for (const account of accounts) {
if (
signal.aborted ||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
@@ -403,24 +437,46 @@ export class RateLimitService {
this.pushToRenderer()
continue
}
const cached = this.inactiveClaudeCache.get(account.id) ?? null
this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
} catch {
// Why: per-account try/catch prevents one Keychain rejection or
// network error from aborting the remaining accounts in the batch.
if (
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
this.inactiveClaudeCache.delete(account.id)
try {
const fresh = await fetchManagedAccountUsage(account, {
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
networkProxySettings: this.networkProxySettingsResolver?.(),
signal
})
if (
signal.aborted ||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
this.inactiveClaudeFetching.delete(account.id)
if (!this.isCurrentInactiveClaudeAccount(account.id)) {
this.inactiveClaudeCache.delete(account.id)
}
this.pushToRenderer()
continue
}
const cached = this.inactiveClaudeCache.get(account.id) ?? null
this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
} catch {
// Why: per-account try/catch prevents one Keychain rejection or
// network error from aborting the remaining accounts in the batch.
if (
signal.aborted ||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
this.inactiveClaudeCache.delete(account.id)
}
}
this.inactiveClaudeFetching.delete(account.id)
this.pushToRenderer()
}
this.inactiveClaudeFetching.delete(account.id)
this.pushToRenderer()
}
if (fetchGeneration === this.inactiveClaudeAccountsGeneration) {
this.lastInactiveClaudeFetchAt = Date.now()
if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) {
this.lastInactiveClaudeFetchAt = Date.now()
}
} finally {
this.finishFetchCycle(controller)
}
}
@@ -439,36 +495,18 @@ export class RateLimitService {
// Why: account switching can make a previewed account active while its
// RPC-only usage fetch is still in flight; stale results must be ignored.
const fetchGeneration = this.inactiveCodexAccountsGeneration
const controller = this.beginFetchCycle()
const signal = controller.signal
for (const account of accounts) {
this.inactiveCodexFetching.add(account.id)
}
this.pushToRenderer()
for (const account of accounts) {
if (
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
!this.isCurrentInactiveCodexAccount(account.id)
) {
this.inactiveCodexFetching.delete(account.id)
if (!this.isCurrentInactiveCodexAccount(account.id)) {
this.inactiveCodexCache.delete(account.id)
}
this.pushToRenderer()
continue
}
try {
// Why: fetchCodexRateLimits already accepts codexHomePath, so we can
// point it at the managed account's home directory directly without
// materializing credentials into the shared runtime location.
// Why: opening the account switcher should never start hidden PTYs for
// every inactive account. On Windows that fallback can crash inside
// ConPTY; RPC-only is enough for this non-critical preview surface.
const fresh = await fetchCodexRateLimits({
codexHomePath: account.managedHomePath,
allowPtyFallback: false
})
try {
for (const account of accounts) {
if (
signal.aborted ||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
!this.isCurrentInactiveCodexAccount(account.id)
) {
@@ -479,23 +517,51 @@ export class RateLimitService {
this.pushToRenderer()
continue
}
const cached = this.inactiveCodexCache.get(account.id) ?? null
this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached))
} catch {
// Why: per-account try/catch prevents one failure from aborting the batch.
if (
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
!this.isCurrentInactiveCodexAccount(account.id)
) {
this.inactiveCodexCache.delete(account.id)
try {
// Why: fetchCodexRateLimits already accepts codexHomePath, so we can
// point it at the managed account's home directory directly without
// materializing credentials into the shared runtime location.
// Why: opening the account switcher should never start hidden PTYs for
// every inactive account. On Windows that fallback can crash inside
// ConPTY; RPC-only is enough for this non-critical preview surface.
const fresh = await fetchCodexRateLimits({
codexHomePath: account.managedHomePath,
allowPtyFallback: false,
signal
})
if (
signal.aborted ||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
!this.isCurrentInactiveCodexAccount(account.id)
) {
this.inactiveCodexFetching.delete(account.id)
if (!this.isCurrentInactiveCodexAccount(account.id)) {
this.inactiveCodexCache.delete(account.id)
}
this.pushToRenderer()
continue
}
const cached = this.inactiveCodexCache.get(account.id) ?? null
this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached))
} catch {
// Why: per-account try/catch prevents one failure from aborting the batch.
if (
signal.aborted ||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
!this.isCurrentInactiveCodexAccount(account.id)
) {
this.inactiveCodexCache.delete(account.id)
}
}
this.inactiveCodexFetching.delete(account.id)
this.pushToRenderer()
}
this.inactiveCodexFetching.delete(account.id)
this.pushToRenderer()
}
if (fetchGeneration === this.inactiveCodexAccountsGeneration) {
this.lastInactiveCodexFetchAt = Date.now()
if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) {
this.lastInactiveCodexFetchAt = Date.now()
}
} finally {
this.finishFetchCycle(controller)
}
}
@@ -644,8 +710,13 @@ export class RateLimitService {
try {
let shouldContinue = true
while (shouldContinue) {
await this.runFetchAllCycle()
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchAllCycle(fetchSignal)
)
shouldContinue = false
if (signal.aborted) {
break
}
if (this.fullFetchQueued) {
this.fullFetchQueued = false
shouldContinue = true
@@ -653,11 +724,21 @@ export class RateLimitService {
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
await this.runFetchCodexOnlyCycle()
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchCodexOnlyCycle(fetchSignal)
)
if (codexSignal.aborted) {
break
}
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
await this.runFetchClaudeOnlyCycle()
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchClaudeOnlyCycle(fetchSignal)
)
if (claudeSignal.aborted) {
break
}
}
}
} finally {
@@ -679,11 +760,21 @@ export class RateLimitService {
try {
let shouldContinue = true
while (shouldContinue) {
await this.runFetchCodexOnlyCycle()
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchCodexOnlyCycle(fetchSignal)
)
shouldContinue = false
if (signal.aborted) {
break
}
if (this.fullFetchQueued) {
this.fullFetchQueued = false
await this.runFetchAllCycle()
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchAllCycle(fetchSignal)
)
if (fullSignal.aborted) {
break
}
continue
}
if (this.codexOnlyFetchQueued) {
@@ -692,7 +783,12 @@ export class RateLimitService {
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
await this.runFetchClaudeOnlyCycle()
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchClaudeOnlyCycle(fetchSignal)
)
if (claudeSignal.aborted) {
break
}
}
}
} finally {
@@ -714,11 +810,21 @@ export class RateLimitService {
try {
let shouldContinue = true
while (shouldContinue) {
await this.runFetchClaudeOnlyCycle()
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchClaudeOnlyCycle(fetchSignal)
)
shouldContinue = false
if (signal.aborted) {
break
}
if (this.fullFetchQueued) {
this.fullFetchQueued = false
await this.runFetchAllCycle()
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchAllCycle(fetchSignal)
)
if (fullSignal.aborted) {
break
}
continue
}
if (this.claudeOnlyFetchQueued) {
@@ -727,7 +833,12 @@ export class RateLimitService {
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
await this.runFetchCodexOnlyCycle()
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchCodexOnlyCycle(fetchSignal)
)
if (codexSignal.aborted) {
break
}
}
}
} finally {
@@ -769,6 +880,49 @@ export class RateLimitService {
}
}
private beginFetchCycle(): AbortController {
const controller = new AbortController()
this.activeFetchAbortControllers.add(controller)
return controller
}
private finishFetchCycle(controller: AbortController): void {
this.activeFetchAbortControllers.delete(controller)
}
private async runWithFetchAbortSignal(
fn: (signal: AbortSignal) => Promise<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
@@ -833,9 +987,45 @@ export class RateLimitService {
return process.platform !== 'win32'
}
private resolveMiniMaxConfig(): MiniMaxResolvedConfig {
try {
return {
config: this.miniMaxConfigResolver?.() ?? {
sessionCookie: '',
groupId: '',
models: 'general'
},
error: null
}
} catch (error) {
// Why: one unreadable browser cookie must not abort every provider's
// quota refresh; surface it as MiniMax-only state instead.
return {
config: {
sessionCookie: '',
groupId: '',
models: 'general'
},
error: toErrorMessage(error)
}
}
}
private getMiniMaxCredentialError(message: string): ProviderRateLimits {
return {
provider: 'minimax',
session: null,
weekly: null,
updatedAt: Date.now(),
error: message,
status: 'error',
usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' }
}
}
private withFetchingStatus(
current: ProviderRateLimits | null,
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax'
): ProviderRateLimits {
if (!current) {
return {
@@ -850,9 +1040,15 @@ export class RateLimitService {
return { ...current, status: 'fetching' }
}
private async runFetchAllCycle(): Promise<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
@@ -863,6 +1059,10 @@ export class RateLimitService {
const openCodeGoConfig = this.openCodeGoConfigResolver?.()
const cookie = openCodeGoConfig?.sessionCookie ?? ''
const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
const miniMaxConfigResult = this.resolveMiniMaxConfig()
const miniMaxCookie = miniMaxConfigResult.config.sessionCookie
const miniMaxGroupId = miniMaxConfigResult.config.groupId
const miniMaxModels = miniMaxConfigResult.config.models
const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false
// Detect if configuration changed — if it did, we must discard any stale
@@ -875,6 +1075,14 @@ export class RateLimitService {
}
const opencodeGeneration = this.opencodeFetchGeneration
const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}`
const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash
if (miniMaxConfigChanged) {
this.lastMiniMaxConfigHash = currentMiniMaxConfigHash
this.minimaxFetchGeneration += 1
}
const miniMaxGeneration = this.minimaxFetchGeneration
// Mark all providers as fetching while keeping previous data visible.
// Codex account changes clear Codex separately before this method is
// called, so ordinary refreshes still preserve the current values.
@@ -886,30 +1094,46 @@ export class RateLimitService {
opencodeGo: opencodeConfigChanged
? this.withFetchingStatus(null, 'opencode-go')
: this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'),
kimi: this.withFetchingStatus(previousState.kimi, 'kimi')
kimi: this.withFetchingStatus(previousState.kimi, 'kimi'),
minimax: miniMaxConfigChanged
? this.withFetchingStatus(null, 'minimax')
: this.withFetchingStatus(previousState.minimax, 'minimax')
})
const missingWslCodexHome = codexHomePath
? null
: this.getMissingWslCodexHomeResult(codexTarget)
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult] =
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] =
await Promise.allSettled([
fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
networkProxySettings: this.networkProxySettingsResolver?.()
networkProxySettings: this.networkProxySettingsResolver?.(),
signal
}),
missingWslCodexHome ??
fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback()
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
signal
}),
fetchGeminiRateLimits(geminiCliOAuthEnabled),
fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined),
fetchKimiRateLimits()
fetchKimiRateLimits(),
miniMaxConfigResult.error
? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
: fetchMiniMaxRateLimits({
cookie: miniMaxCookie,
groupId: miniMaxGroupId,
models: miniMaxModels
})
])
if (signal.aborted) {
return
}
const claude =
claudeResult.status === 'fulfilled'
? claudeResult.value
@@ -977,8 +1201,26 @@ export class RateLimitService {
status: 'error'
} satisfies ProviderRateLimits)
const miniMax =
miniMaxResult.status === 'fulfilled'
? miniMaxResult.value
: ({
provider: 'minimax',
session: null,
weekly: null,
updatedAt: Date.now(),
error:
miniMaxResult.reason instanceof Error
? miniMaxResult.reason.message
: 'Unknown error',
status: 'error'
} satisfies ProviderRateLimits)
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
if (signal.aborted) {
return
}
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
const shouldApplyCodex =
@@ -988,6 +1230,7 @@ export class RateLimitService {
claudeProvenance === latestClaudeProvenance &&
this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget)
const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration
const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration
// Why: account switches can race in-flight Codex fetches. Only apply a
// Codex result if both the selected-account provenance and the request
@@ -1007,13 +1250,21 @@ export class RateLimitService {
? opencodeGo
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
: this.state.opencodeGo,
kimi: this.applyStalePolicy(kimi, previousState.kimi)
kimi: this.applyStalePolicy(kimi, previousState.kimi),
minimax: shouldApplyMiniMax
? miniMaxConfigChanged
? miniMax
: this.applyStalePolicy(miniMax, previousState.minimax)
: this.state.minimax
})
this.lastFetchAt = Date.now()
}
private async runFetchCodexOnlyCycle(): Promise<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 +1284,8 @@ export class RateLimitService {
? Promise.resolve(missingWslCodexHome)
: fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback()
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
signal
})
).catch(
(err): ProviderRateLimits => ({
@@ -1046,6 +1298,10 @@ export class RateLimitService {
})
)
if (signal.aborted) {
return
}
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
const shouldApplyCodex =
@@ -1059,9 +1315,15 @@ export class RateLimitService {
this.lastFetchAt = Date.now()
}
private async runFetchClaudeOnlyCycle(): Promise<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 +1337,8 @@ export class RateLimitService {
authPreparation: claudeAuthPreparation,
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
networkProxySettings: this.networkProxySettingsResolver?.()
networkProxySettings: this.networkProxySettingsResolver?.(),
signal
}).catch(
(err): ProviderRateLimits => ({
provider: 'claude',
@@ -1087,7 +1350,14 @@ export class RateLimitService {
})
)
if (signal.aborted) {
return
}
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
if (signal.aborted) {
return
}
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
const shouldApplyClaude =
claudeGeneration === this.claudeFetchGeneration &&
+205 -15
View File
@@ -811,6 +811,16 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-
const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
function isOriginMainBaseRefProbe(args: string[]): boolean {
return (
args[0] === 'rev-parse' &&
args[1] === '--verify' &&
(args.includes('origin/main') ||
args.includes('refs/remotes/origin/main') ||
args.includes('refs/remotes/origin/main^{commit}'))
)
}
function antigravityReadyScreen(model = 'Gemini 3.5 Flash (High)'): string {
return [
'Antigravity CLI 1.0.3',
@@ -1218,27 +1228,33 @@ computeWorktreePathMock.mockImplementation(
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
describe('OrcaRuntimeService', () => {
it('projects worktree card display settings to paired clients', () => {
it('projects runtime-backed settings to paired clients', () => {
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({
...store.getSettings(),
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5'
})
} as never)
expect(runtime.getClientSettings()).toMatchObject({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5'
})
})
it('accepts worktree card display setting updates from paired clients', () => {
it('accepts runtime-backed setting updates from paired clients', () => {
let settings = {
...store.getSettings(),
experimentalNewWorktreeCardStyle: false,
compactWorktreeCards: false
compactWorktreeCards: false,
minimaxGroupId: '',
minimaxUsageModels: 'general'
}
const updateSettings = vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
@@ -1253,19 +1269,30 @@ describe('OrcaRuntimeService', () => {
expect(
runtime.updateClientSettings({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5'
})
).toMatchObject({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5'
})
expect(updateSettings).toHaveBeenCalledWith(
{ experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true },
{
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5'
},
{ notifyListeners: true }
)
expect(runtime.getClientSettings()).toMatchObject({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5'
})
})
@@ -2370,9 +2397,8 @@ describe('OrcaRuntimeService', () => {
}
})
it('creates a runtime local worktree from an existing local base ref when the refresh fails', async () => {
// Regression: an offline/auth failure while refreshing the remote-tracking
// base must not block creation when a usable local base ref already exists.
it('creates a runtime local worktree from the detected default when the persisted base is stale', async () => {
// Regression: a stale persisted repo base must fall back to the detected default.
const runtime = new OrcaRuntimeService(store)
const createdWorktree = {
path: '/tmp/workspaces/cli-refresh-fails',
@@ -2381,6 +2407,8 @@ describe('OrcaRuntimeService', () => {
isBare: false,
isMainWorktree: false
}
const repo = { ...store.getRepos()[0], worktreeBaseRef: 'origin/master' }
const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(addWorktree).mockResolvedValueOnce({})
@@ -2392,8 +2420,10 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
return { stdout: '/tmp/repo/.git\n', stderr: '' }
}
// Local remote-tracking base ref resolves -> usable fallback exists.
if (args[0] === 'rev-parse' && args[1] === '--verify') {
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
throw new Error('missing ref')
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
return { stdout: 'base-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
@@ -2409,8 +2439,135 @@ describe('OrcaRuntimeService', () => {
})
).resolves.toBeDefined()
expect(addWorktree).toHaveBeenCalled()
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
createdWorktree.path,
'cli-refresh-fails',
'origin/main',
false,
false,
{
remoteTrackingBase: {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
},
suggestLocalBaseRefUpdate: true
}
)
expect(getDefaultBaseRef).toHaveBeenCalled()
} finally {
getReposSpy.mockRestore()
gitSpy.mockRestore()
}
})
it('creates a runtime local worktree from a usable persisted local branch base', async () => {
const runtime = new OrcaRuntimeService(store)
const createdWorktree = {
path: '/tmp/workspaces/local-branch-base',
head: 'develop-sha',
branch: 'local-branch-base',
isBare: false,
isMainWorktree: false
}
const repo = { ...store.getRepos()[0], worktreeBaseRef: 'develop' }
const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(addWorktree).mockResolvedValueOnce({})
vi.mocked(listWorktrees).mockResolvedValue([createdWorktree])
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
return { stdout: '/tmp/repo/.git\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
return { stdout: 'develop-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
throw new Error('network unavailable')
}
return { stdout: '', stderr: '' }
})
try {
await expect(
runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'local-branch-base'
})
).resolves.toBeDefined()
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
createdWorktree.path,
'local-branch-base',
'develop',
false
)
} finally {
getReposSpy.mockRestore()
gitSpy.mockRestore()
}
})
it('creates a runtime local worktree from a slash-named local branch matching a remote prefix', async () => {
const runtime = new OrcaRuntimeService(store)
const createdWorktree = {
path: '/tmp/workspaces/slash-local-base',
head: 'team-feature-sha',
branch: 'slash-local-base',
isBare: false,
isMainWorktree: false
}
const repo = { ...store.getRepos()[0], worktreeBaseRef: 'team/feature' }
const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(addWorktree).mockResolvedValueOnce({})
vi.mocked(listWorktrees).mockResolvedValue([createdWorktree])
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'remote') {
return { stdout: 'team\norigin\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
return { stdout: '/tmp/repo/.git\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
throw new Error('missing remote-tracking ref')
}
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
return { stdout: 'team-feature-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
throw new Error('network unavailable')
}
return { stdout: '', stderr: '' }
})
try {
await expect(
runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'slash-local-base'
})
).resolves.toBeDefined()
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
createdWorktree.path,
'slash-local-base',
'team/feature',
false
)
expect(gitSpy).not.toHaveBeenCalledWith(
['fetch', '--no-tags', 'team', '+refs/heads/feature:refs/remotes/team/feature'],
expect.any(Object)
)
} finally {
getReposSpy.mockRestore()
gitSpy.mockRestore()
}
})
@@ -3140,6 +3297,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3244,6 +3404,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3397,6 +3560,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3507,6 +3673,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3609,6 +3778,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -3765,6 +3937,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -4840,6 +5015,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
@@ -22332,6 +22510,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -22425,6 +22606,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -22535,6 +22719,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'fetch') {
return { stdout: '', stderr: '' }
}
@@ -23216,6 +23403,9 @@ describe('OrcaRuntimeService', () => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
if (isOriginMainBaseRefProbe(args)) {
return { stdout: 'main-sha\n', stderr: '' }
}
if (args[0] === 'config') {
return { stdout: 'origin\n', stderr: '' }
}
+116 -34
View File
@@ -46,6 +46,8 @@ import { createHash, randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { isAbsolute, join, resolve } from 'node:path'
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagesForInjection } from './orchestration/formatter'
import type {
@@ -581,7 +583,8 @@ import {
getRemoteDrift,
getRecentDriftSubjects
} from '../git/repo'
import { hasLocalCommitObject } from '../git/commit-object-ref'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe'
import { resolveLocalGitUsername } from '../git/git-username'
import {
listWorktrees,
@@ -793,6 +796,8 @@ type RuntimeStore = {
githubProjects?: GlobalSettings['githubProjects']
experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle']
compactWorktreeCards?: GlobalSettings['compactWorktreeCards']
minimaxGroupId?: GlobalSettings['minimaxGroupId']
minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
gitlabProjects?: GlobalSettings['gitlabProjects']
experimentalWorktreeSymlinks?: boolean
mobileAutoRestoreFitMs?: number | null
@@ -1963,6 +1968,26 @@ type LayoutQueueEntry = {
}[]
}
async function hasLocalWorktreeBaseRef(
repoPath: string,
baseRef: string,
options: { wslDistro?: string } = {}
): Promise<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()
@@ -2372,6 +2397,8 @@ export class OrcaRuntimeService {
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
> {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
@@ -2391,7 +2418,9 @@ export class OrcaRuntimeService {
defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null,
githubProjects: settings.githubProjects,
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
compactWorktreeCards: settings.compactWorktreeCards === true
compactWorktreeCards: settings.compactWorktreeCards === true,
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general'
}
}
@@ -2411,6 +2440,8 @@ export class OrcaRuntimeService {
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
>
): Pick<
GlobalSettings,
@@ -2428,6 +2459,8 @@ export class OrcaRuntimeService {
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
@@ -5922,7 +5955,9 @@ export class OrcaRuntimeService {
})
}
private async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
// Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile
// mirror or a resubscribing mobile client resurrects the cleared scrollback.
async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
return
@@ -12992,12 +13027,42 @@ export class OrcaRuntimeService {
let effectiveSanitizedName = sanitizedName
const username = await resolveLocalGitUsername(repo.path)
const baseBranch =
args.baseBranch ||
repo.worktreeBaseRef ||
(hasLocalWorktreeGitOptions
? await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
: getDefaultBaseRef(repo.path))
const baseBranch = await resolveWorktreeCreateBase({
requestedBaseBranch: args.baseBranch,
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: () =>
hasLocalWorktreeGitOptions
? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
: Promise.resolve(getDefaultBaseRef(repo.path)),
isBaseUsable: async (baseBranchCandidate) => {
const remoteTrackingBase = await this.resolveRemoteTrackingBase(
repo.path,
baseBranchCandidate,
...localWorktreeGitOptionArgs
)
if (remoteTrackingBase) {
if (
await this.hasRemoteTrackingRef(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
) {
return true
}
return hasLocalWorktreeBaseRef(
repo.path,
baseBranchCandidate,
hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
)
}
return hasLocalWorktreeBaseRef(
repo.path,
baseBranchCandidate,
hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
)
}
})
if (!baseBranch) {
// Why: getDefaultBaseRef returns null when no suitable ref exists.
// Don't fabricate 'origin/main' — passing it to addWorktree would
@@ -13128,44 +13193,61 @@ export class OrcaRuntimeService {
`Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.`
)
}
const remoteTrackingBase = await this.resolveRemoteTrackingBase(
let remoteTrackingBase = await this.resolveRemoteTrackingBase(
repo.path,
baseBranch,
...localWorktreeGitOptionArgs
)
if (remoteTrackingBase) {
const hadLocalBaseRef = await this.hasRemoteTrackingRef(
const hadRemoteTrackingBaseRef = await this.hasRemoteTrackingRef(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
)
if (!refreshResult.ok && !hadLocalBaseRef) {
// Why: only block creation when the refresh failed AND there is no
// usable local base ref to fall back on. If a local remote-tracking ref
// already exists, `git worktree add` can create from it — a possibly
// stale but valid base — so a transient offline/auth failure must not
// make the workspace uncreatable. The compare-to-base view reflects any
// drift once the remote is reachable again.
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
)
}
if (
!hadLocalBaseRef &&
!(await this.hasRemoteTrackingRef(
const hasLocalBaseRef =
hadRemoteTrackingBaseRef ||
(await hasLocalWorktreeBaseRef(
repo.path,
baseBranch,
hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
))
if (!hadRemoteTrackingBaseRef && hasLocalBaseRef) {
remoteTrackingBase = null
} else {
const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
))
) {
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
)
if (!refreshResult.ok && !hadRemoteTrackingBaseRef) {
// Why: only block creation when the refresh failed AND there is no
// usable local base ref to fall back on. If a local remote-tracking ref
// already exists, `git worktree add` can create from it — a possibly
// stale but valid base — so a transient offline/auth failure must not
// make the workspace uncreatable. The compare-to-base view reflects any
// drift once the remote is reachable again.
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
)
}
if (
!hadRemoteTrackingBaseRef &&
!(await this.hasRemoteTrackingRef(
repo.path,
remoteTrackingBase,
...localWorktreeGitOptionArgs
))
) {
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
}
}
} else if (!(await hasLocalCommitObject(repo.path, baseBranch))) {
} else if (
!(await hasLocalWorktreeBaseRef(
repo.path,
baseBranch,
hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
))
) {
// Why: local bases keep legacy best-effort fetch behavior. Verified PR
// SHA bases already have the commit object needed by `git worktree add`.
try {
+14 -2
View File
@@ -22,6 +22,8 @@ describe('client UI RPC methods', () => {
defaultRepoSelection: ['repo-1'],
defaultLinearTeamSelection: ['team-1'],
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5',
githubProjects: {
pinned: [],
recent: [],
@@ -77,6 +79,8 @@ describe('client UI RPC methods', () => {
defaultTaskViewPreset: 'my-prs',
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5',
defaultRepoSelection: settings.defaultRepoSelection,
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: settings.githubProjects
@@ -91,6 +95,8 @@ describe('client UI RPC methods', () => {
defaultTaskViewPreset: 'my-prs',
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5',
defaultRepoSelection: settings.defaultRepoSelection,
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: settings.githubProjects
@@ -176,7 +182,10 @@ describe('client UI RPC methods', () => {
...getDefaultUIState(),
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
statusBarItems: ['codex'],
statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',
@@ -212,7 +221,10 @@ describe('client UI RPC methods', () => {
const payload = {
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
statusBarItems: ['codex'],
statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',
+16 -1
View File
@@ -40,7 +40,17 @@ const WorktreeCardProperties = z
.array(LegacyWorktreeCardProperty)
.transform((value) => normalizeWorktreeCardProperties(value))
const AgentActivityDisplayMode = z.enum(['compact', 'full'])
const StatusBarItem = z.enum(['claude', 'codex', 'gemini', 'opencode-go', 'ssh', 'resource-usage'])
const StatusBarItem = z.enum([
'claude',
'codex',
'gemini',
'opencode-go',
'kimi',
'minimax',
'ssh',
'resource-usage',
'ports'
])
const WorkspaceStatusDefinition = z.object({
id: z.string(),
label: z.string(),
@@ -142,6 +152,8 @@ const SettingsUpdate = z
defaultRepoSelection: z.array(z.string()).nullable().optional(),
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
compactWorktreeCards: z.boolean().optional(),
minimaxGroupId: z.string().optional(),
minimaxUsageModels: z.string().optional(),
githubProjects: GitHubProjectSettings.optional()
})
.strict()
@@ -188,6 +200,9 @@ const UiUpdate = z
_workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(),
_workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(),
statusBarItems: z.array(StatusBarItem).optional(),
_portsStatusBarDefaultAdded: z.boolean().optional(),
_kimiStatusBarDefaultAdded: z.boolean().optional(),
_minimaxStatusBarDefaultAdded: z.boolean().optional(),
statusBarVisible: z.boolean().optional(),
dismissedUpdateVersion: NullableString.optional(),
lastUpdateCheckAt: z.number().finite().nullable().optional(),
+24
View File
@@ -62,10 +62,12 @@ import {
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
type RemoteCliBridgeEnv = {
remoteHome: string
binDir: string
relayDir: string
nodePath: string
@@ -79,6 +81,13 @@ type ForwardedReplayFingerprint = {
deliveredAt: number
}
export type SshRelayAiVaultHostInfo = {
targetId: string
executionHostId: ExecutionHostId
remoteHome: string
hostPlatform: RemoteHostPlatform
}
const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000
const REPLAY_FINGERPRINT_EDGE_CHARS = 128
@@ -189,6 +198,19 @@ export class SshRelaySession {
return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform
}
getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null {
const env = this.remoteCliBridgeEnv
if (!env) {
return null
}
return {
targetId: this.targetId,
executionHostId: toSshExecutionHostId(this.targetId),
remoteHome: env.remoteHome,
hostPlatform: env.hostPlatform
}
}
getPortScanner(): PortScanner | null {
return this.portScanner
}
@@ -218,6 +240,7 @@ export class SshRelaySession {
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
remoteHome,
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
relayDir: remoteRelayDir,
nodePath,
@@ -345,6 +368,7 @@ export class SshRelaySession {
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
remoteHome,
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
relayDir: remoteRelayDir,
nodePath,
@@ -0,0 +1,93 @@
import type { SshConnection } from './ssh-connection'
import { createSshOperationAbortError } from './ssh-connection-utils'
import { execCommand } from './ssh-relay-deploy-helpers'
import { isSshSessionLimitError } from './ssh-session-limit-error'
export type RemoteNodeResolutionOptions = {
rethrowSessionLimitErrors?: boolean
signal?: AbortSignal
}
const NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS = 8_000
const NODE_PACKAGE_MANAGER_HINTS: readonly { bin: string; label: string; install: string }[] = [
{ bin: 'apt-get', label: 'Debian/Ubuntu', install: 'sudo apt-get install -y nodejs npm' },
{ bin: 'dnf', label: 'Fedora/RHEL', install: 'sudo dnf install -y nodejs npm' },
{ bin: 'yum', label: 'RHEL/CentOS', install: 'sudo yum install -y nodejs npm' },
{ bin: 'pacman', label: 'Arch', install: 'sudo pacman -S --needed nodejs npm' },
{ bin: 'apk', label: 'Alpine', install: 'sudo apk add nodejs npm' },
{ bin: 'zypper', label: 'openSUSE', install: 'sudo zypper install -y nodejs npm' },
{ bin: 'brew', label: 'macOS/Homebrew', install: 'brew install node' }
]
export async function buildPosixNodeInstallGuidance(
conn: SshConnection,
options?: RemoteNodeResolutionOptions
): Promise<string> {
const detectedBin = await detectPackageManager(conn, options)
return formatNodeInstallHints(detectedBin)
}
async function detectPackageManager(
conn: SshConnection,
options?: RemoteNodeResolutionOptions
): Promise<string | null> {
throwIfAborted(options)
const bins = NODE_PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ')
try {
const output = await execCommand(
conn,
`for p in ${bins}; do if command -v "$p" >/dev/null 2>&1; then echo "$p"; break; fi; done`,
commandOptions(options)
)
const detected = output.trim().split('\n')[0]
return NODE_PACKAGE_MANAGER_HINTS.some((hint) => hint.bin === detected) ? detected : null
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
return null
}
}
function formatNodeInstallHints(detectedBin: string | null): string {
const tailored = detectedBin
? NODE_PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === detectedBin)
: null
const lines = [
'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.',
'',
'Install Node.js 18+ with npm on the remote host, then reconnect:'
]
if (tailored) {
lines.push(` ${tailored.label}: ${tailored.install}`)
} else {
for (const hint of NODE_PACKAGE_MANAGER_HINTS) {
lines.push(` ${hint.label}: ${hint.install}`)
}
}
lines.push(
'',
'Verify the remote runtime before reconnecting:',
' node --version # must be v18 or newer',
' npm --version',
'',
'If your distro package is older than Node 18, install an LTS release from https://nodejs.org/.'
)
return lines.join('\n')
}
function commandOptions(options?: RemoteNodeResolutionOptions): {
timeoutMs: number
signal?: AbortSignal
} {
const base = { timeoutMs: NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS }
return options?.signal ? { ...base, signal: options.signal } : base
}
function throwIfAborted(options?: RemoteNodeResolutionOptions): void {
if (options?.signal?.aborted) {
throw createSshOperationAbortError()
}
}
@@ -18,6 +18,14 @@ const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution')
const conn = {} as SshConnection
function decodePowerShellCommand(command: string): string {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
if (!match) {
throw new Error(`No encoded PowerShell command found in: ${command}`)
}
return Buffer.from(match[1]!, 'base64').toString('utf16le')
}
describe('resolveRemoteNodePath', () => {
beforeEach(() => {
execCommandMock.mockReset()
@@ -88,10 +96,23 @@ describe('resolveRemoteNodePath', () => {
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node')
expect(callScript).toContain('"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node')
expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node')
expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node')
})
it('probes fnm XDG data directory installs', async () => {
execCommandMock
.mockResolvedValueOnce(
'/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node\n'
)
.mockResolvedValueOnce('v24.18.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
'/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node'
)
})
it('does not depend on GNU sort when probing version-manager directories', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
@@ -258,8 +279,9 @@ describe('resolveRemoteNodePath', () => {
.mockResolvedValueOnce('\n') // path probe: empty
.mockResolvedValueOnce('/bin/bash') // $SHELL
.mockResolvedValueOnce('\n') // command -v node: empty
.mockResolvedValueOnce('apt-get\n') // package manager hint probe
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo apt-get install -y nodejs npm/)
})
it('throws when the path-probe exec fails and the login shell finds nothing', async () => {
@@ -267,8 +289,9 @@ describe('resolveRemoteNodePath', () => {
.mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors
.mockResolvedValueOnce('/bin/zsh') // $SHELL
.mockResolvedValueOnce('\n') // command -v node: empty
.mockRejectedValueOnce(new Error('package manager probe failed'))
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Debian\/Ubuntu:/)
})
it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => {
@@ -318,6 +341,21 @@ describe('resolveRemoteNodePath', () => {
).rejects.toBe(sessionLimitError)
})
it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock
.mockResolvedValueOnce('\n')
.mockResolvedValueOnce('/bin/bash')
.mockResolvedValueOnce('\n')
.mockRejectedValueOnce(sessionLimitError)
await expect(
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
).rejects.toBe(sessionLimitError)
})
it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
@@ -331,6 +369,21 @@ describe('resolveRemoteNodePath', () => {
).rejects.toBe(sessionLimitError)
})
it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock
.mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n')
.mockRejectedValueOnce(sessionLimitError)
await expect(
resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), {
rethrowSessionLimitErrors: true
})
).rejects.toBe(sessionLimitError)
})
it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => {
// Why: in concurrent bootstrap a sibling probe aborts this resolution via
// the shared signal. Each strategy catch swallows the injected AbortError,
@@ -357,7 +410,32 @@ describe('resolveRemoteNodePath', () => {
.mockResolvedValueOnce('/bin/bash') // $SHELL
.mockResolvedValueOnce('/old/node2\n') // login shell
.mockResolvedValueOnce('v6.17.0\n') // too old
.mockResolvedValueOnce('dnf\n') // package manager hint probe
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo dnf install -y nodejs npm/)
})
it('rejects a Windows node.exe below the minimum version', async () => {
execCommandMock
.mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') // Get-Command / common paths
.mockResolvedValueOnce('v16.20.2\n') // version check: too old
await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).rejects.toThrow(
/winget install OpenJS\.NodeJS\.LTS/
)
})
it('does not stop Windows node discovery after the first existing candidate', async () => {
execCommandMock
.mockResolvedValueOnce('C:\\old\\node.exe\nC:\\Program Files\\nodejs\\node.exe\n')
.mockResolvedValueOnce('v16.20.2\n')
.mockResolvedValueOnce('v20.11.0\n')
await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe(
'C:/Program Files/nodejs/node.exe'
)
const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string)
expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/)
})
})
+130 -47
View File
@@ -2,7 +2,11 @@ import type { SshConnection } from './ssh-connection'
import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform'
import { powerShellCommand } from './ssh-remote-powershell'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
import {
buildPosixNodeInstallGuidance,
type RemoteNodeResolutionOptions
} from './ssh-remote-node-install-guidance'
import { execCommand } from './ssh-relay-deploy-helpers'
import { isSshSessionLimitError } from './ssh-session-limit-error'
@@ -16,11 +20,6 @@ const MIN_NODE_MAJOR = 18
// hang a login shell, so keep this short.
const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000
type RemoteNodeResolutionOptions = {
rethrowSessionLimitErrors?: boolean
signal?: AbortSignal
}
export async function resolveRemoteNodePath(
conn: SshConnection,
host?: RemoteHostPlatform,
@@ -47,7 +46,7 @@ export async function resolveRemoteNodePath(
return loginShellPath
}
throwNodeNotFound(options)
return throwNodeNotFound(conn, options)
}
// Probe the on-disk install directories of every common Node version manager
@@ -91,6 +90,7 @@ for candidate in \\
"$HOME/.local/bin/node" \\
"$HOME/.fnm/aliases/default/bin/node" \\
"$HOME/.fnm/node-versions"/*/installation/bin/node \\
"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\
"$HOME/.local/share/mise/shims/node" \\
"$HOME/.local/share/mise/installs/node"/*/bin/node \\
"$HOME/.asdf/shims/node" \\
@@ -104,7 +104,7 @@ true
`
try {
const result = await execCommand(conn, script, { signal: options?.signal })
const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options))
const seen = new Set<string>()
for (const line of result.split('\n')) {
const candidate = line.trim()
@@ -138,20 +138,21 @@ async function tryResolveViaLoginShell(
// Using it — rather than hardcoding bash — means zsh/fish users whose
// custom PATH hooks live in profile files get coverage too. We fall back
// to sh if $SHELL is unset (rare, e.g. restricted accounts).
const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', {
timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS,
signal: options?.signal
})
const shellResult = await execCommand(
conn,
'echo "${SHELL:-/bin/sh}"',
commandOptions({ timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options)
)
const shell = shellResult.trim().split('\n')[0]
if (!shell) {
return null
}
const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), {
wrapCommand: false,
timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS,
signal: options?.signal
})
const nodePath = await execCommand(
conn,
buildCommandInShell(shell, 'command -v node'),
commandOptions({ wrapCommand: false, timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options)
)
const candidate = nodePath.trim().split('\n')[0]
if (!candidate) {
return null
@@ -187,16 +188,12 @@ async function nodeMeetsVersionRequirement(
options?: RemoteNodeResolutionOptions
): Promise<boolean> {
try {
const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, {
wrapCommand: false,
signal: options?.signal
})
const match = versionOutput.trim().match(/^v?(\d+)/)
if (!match) {
return false
}
const major = Number.parseInt(match[1]!, 10)
return major >= MIN_NODE_MAJOR
const versionOutput = await execCommand(
conn,
`${shellEscape(nodePath)} --version`,
commandOptions({ wrapCommand: false }, options)
)
return nodeVersionMeetsRequirement(versionOutput)
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
@@ -217,26 +214,34 @@ async function resolveRemoteWindowsNodePath(
'if ($env:ProgramFiles) { $paths += (Join-Path $env:ProgramFiles "nodejs/node.exe") }',
'if (${env:ProgramFiles(x86)}) { $paths += (Join-Path ${env:ProgramFiles(x86)} "nodejs/node.exe") }',
'if ($env:LOCALAPPDATA) { $paths += (Join-Path $env:LOCALAPPDATA "Programs/nodejs/node.exe") }',
'$found = $false',
'foreach ($path in $paths) {',
' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) {',
' Write-Output $path',
' exit 0',
' $found = $true',
' }',
'}',
'if ($found) { exit 0 }',
"Write-Error 'Node.js not found'",
'exit 1'
].join('\n')
try {
const result = await execCommand(conn, powerShellCommand(script), {
wrapCommand: false,
signal: options?.signal
})
const nodePath = result.trim().split('\n')[0]
if (nodePath) {
const result = await execCommand(
conn,
powerShellCommand(script),
commandOptions({ wrapCommand: false }, options)
)
for (const line of result.split('\n')) {
const nodePath = line.trim()
if (!nodePath) {
continue
}
const normalized = normalizeWindowsRemotePath(nodePath)
console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
return normalized
if (await windowsNodeMeetsVersionRequirement(conn, normalized, options)) {
console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
return normalized
}
}
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
@@ -245,20 +250,98 @@ async function resolveRemoteWindowsNodePath(
// Fall through to the shared error below.
}
throwNodeNotFound(options)
throwWindowsNodeNotFound(options)
}
function throwNodeNotFound(options?: RemoteNodeResolutionOptions): never {
// Why: when a sibling bootstrap probe aborts this resolution via the shared
// signal, the strategy catch-blocks swallow the injected AbortError and fall
// through to here. Surfacing "Node.js not found" would launder a cancellation
// into a fatal non-session failure, defeating the concurrent-bootstrap
// sequential fallback. Re-raise the abort so callers classify it correctly.
async function windowsNodeMeetsVersionRequirement(
conn: SshConnection,
nodePath: string,
options?: RemoteNodeResolutionOptions
): Promise<boolean> {
try {
const versionOutput = await execCommand(
conn,
powerShellCommand(`& ${powerShellLiteral(nodePath)} --version`),
commandOptions({ wrapCommand: false }, options)
)
return nodeVersionMeetsRequirement(versionOutput)
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
return false
}
}
function nodeVersionMeetsRequirement(versionOutput: string): boolean {
const match = versionOutput.trim().match(/^v?(\d+)/)
if (!match) {
return false
}
const major = Number.parseInt(match[1]!, 10)
return major >= MIN_NODE_MAJOR
}
async function throwNodeNotFound(
conn: SshConnection,
options?: RemoteNodeResolutionOptions
): Promise<never> {
throwIfAborted(options)
const guidance = await buildPosixNodeInstallGuidance(conn, options)
throwIfAborted(options)
throw new Error(guidance)
}
function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never {
throwIfAborted(options)
throw new Error(
[
'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.',
'',
'Install Node.js 18+ on the remote host, then reconnect:',
' winget install OpenJS.NodeJS.LTS',
' choco install nodejs-lts',
'',
'Verify the remote runtime before reconnecting:',
' node --version # must be v18 or newer',
' npm --version',
'',
'If those package managers are unavailable, install an LTS release from https://nodejs.org/.'
].join('\n')
)
}
function throwIfAborted(options?: RemoteNodeResolutionOptions): void {
// Why: strategy fallbacks intentionally swallow probe failures, but a shared
// bootstrap abort must stay an AbortError so callers can continue fallback.
if (options?.signal?.aborted) {
throw createSshOperationAbortError()
}
throw new Error(
'Node.js not found on remote host. Orca relay requires Node.js 18+. ' +
'Install Node.js on the remote and try again.'
)
}
function signalOnlyOptions(
options?: RemoteNodeResolutionOptions
): { signal: AbortSignal } | undefined {
return options?.signal ? { signal: options.signal } : undefined
}
type RemoteExecOptions = {
wrapCommand?: boolean
timeoutMs?: number
signal?: AbortSignal
}
function commandOptions(
base: RemoteExecOptions,
options?: RemoteNodeResolutionOptions
): RemoteExecOptions {
return options?.signal ? { ...base, signal: options.signal } : base
}
async function execCommandWithOptionalOptions(
conn: SshConnection,
command: string,
options?: { signal: AbortSignal }
): Promise<string> {
return options ? execCommand(conn, command, options) : execCommand(conn, command)
}
+80
View File
@@ -187,6 +187,86 @@ describe('fetchNewerReleaseTag', () => {
expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1')
})
it('ignores perf-tagged prereleases for regular RC checks', async () => {
respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.6', 'v1.4.121-rc.5'])
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
expect(await fetchNewerReleaseTag('1.4.121-rc.5', { includePrerelease: true })).toBe(
'v1.4.121-rc.6'
)
})
it('reports no RC update when only perf-tagged prereleases are newer', async () => {
respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.5'])
const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed')
await expect(
fetchNewerReleaseTagsWithReadiness('1.4.121-rc.5', 1, { includePrerelease: true })
).resolves.toEqual({
tags: [],
state: 'no-newer'
})
})
it('picks the semver-newest perf-tagged prerelease', async () => {
respondWithAtom([
'v1.4.121-rc.6.perf',
'v1.4.121-rc.7',
'v1.4.121-rc.5.perf',
'v1.4.121-rc.6',
'v1.4.122'
])
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
expect(
await fetchNewerReleaseTag('1.4.120', {
includePrerelease: true,
releaseFilter: 'perf'
})
).toBe('v1.4.121-rc.6.perf')
})
it('matches only literal rc.N.perf prerelease tags for perf checks', async () => {
respondWithAtom([
'v1.4.121-rc.7.performance',
'v1.4.121-beta.7.perf',
'v1.4.121-rc.7.perf.extra',
'v1.4.121-rc.6.perf'
])
const { fetchNewerReleaseTag, isPerfPrereleaseTag } = await import('./updater-prerelease-feed')
expect(isPerfPrereleaseTag('v1.4.121-rc.6.perf')).toBe(true)
expect(isPerfPrereleaseTag('v1.4.121-rc.7.performance')).toBe(false)
expect(isPerfPrereleaseTag('v1.4.121-beta.7.perf')).toBe(false)
expect(isPerfPrereleaseTag('v1.4.121-rc.7.perf.extra')).toBe(false)
expect(
await fetchNewerReleaseTag('1.4.120', {
includePrerelease: true,
releaseFilter: 'perf'
})
).toBe('v1.4.121-rc.6.perf')
})
it('reports no perf update instead of falling back to stable or RC tags', async () => {
respondWithAtom(['v1.4.122', 'v1.4.121-rc.7', 'v1.4.121'])
const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed')
await expect(
fetchNewerReleaseTagsWithReadiness('1.4.120', 1, {
includePrerelease: true,
releaseFilter: 'perf'
})
).resolves.toEqual({
tags: [],
state: 'no-newer'
})
})
it('returns a bounded fallback candidate after the newest newer tag', async () => {
respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5'])
const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed')
+21 -3
View File
@@ -43,6 +43,18 @@ type ReleaseFeedTag = {
version: string
}
export function isPerfPrereleaseTag(tag: string): boolean {
const version = normalizeTagToVersion(tag)
const match = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-.]+)(?:\+[0-9A-Za-z-.]+)?$/)
const identifiers = match?.[1]?.split('.') ?? []
return (
identifiers.length === 3 &&
identifiers[0] === 'rc' &&
/^\d+$/.test(identifiers[1]) &&
identifiers[2] === 'perf'
)
}
async function fetchReleaseFeedTags(): Promise<ReleaseFeedTag[] | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
@@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise<boolean> {
*/
type FetchNewerReleaseTagOptions = {
includePrerelease?: boolean
releaseFilter?: 'perf'
}
export type FetchNewerReleaseTagsResult = {
@@ -190,9 +203,14 @@ export async function fetchNewerReleaseTagsWithReadiness(
return { tags: [], state: 'unavailable' }
}
const candidates = includePrerelease
? tags
: tags.filter(({ version }) => !isPrereleaseVersion(version))
// Why: perf builds are explicit opt-in; regular prerelease checks should
// stay on the main RC/stable series even though perf tags are semver-newer.
const candidates =
options.releaseFilter === 'perf'
? tags.filter(({ tag }) => isPerfPrereleaseTag(tag))
: includePrerelease
? tags.filter(({ tag }) => !isPerfPrereleaseTag(tag))
: tags.filter(({ version }) => !isPrereleaseVersion(version))
const newestNewerIndex = candidates.findIndex(
({ version }) => compareVersions(version, currentVersion) > 0
)
+91
View File
@@ -1012,6 +1012,97 @@ describe('updater', () => {
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
})
it('pins the generic feed to a perf-tagged prerelease when requested', async () => {
appMock.getVersion.mockReturnValue('1.4.120')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu({ includePerfPrerelease: true })
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
includePrerelease: true,
releaseFilter: 'perf'
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).toBe(true)
})
it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => {
appMock.getVersion.mockReturnValue('1.4.120')
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
checkForUpdatesFromMenu({ includePerfPrerelease: true })
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
includePrerelease: true,
releaseFilter: 'perf'
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls)
})
it('keeps background retries on the stable channel after a perf publishing-window miss', async () => {
vi.useFakeTimers()
appMock.getVersion.mockReturnValue('1.4.120')
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({ tags: [], state: 'not-ready' })
.mockResolvedValueOnce(['v1.4.121'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu({ includePerfPrerelease: true })
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenNthCalledWith(2, '1.4.120', 1, {
includePrerelease: false
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.121'
})
})
})
it('leaves the feed URL alone for a normal user-initiated check', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
+95 -27
View File
@@ -2,7 +2,7 @@
import { app, BrowserWindow, powerMonitor } from 'electron'
import type { NsisUpdater } from 'electron-updater'
import { is } from '@electron-toolkit/utils'
import type { UpdateStatus } from '../shared/types'
import type { UpdateCheckOptions, UpdateStatus } from '../shared/types'
import { killAllPty } from './ipc/pty'
import { withUpdaterSpan } from './observability/instrumentation'
import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader'
@@ -29,6 +29,8 @@ import { fetchNudge, shouldApplyNudge } from './updater-nudge'
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
type PrimaryEventSuppression = { failureKey: string; error: unknown }
type UpdateCheckVariant = 'default' | 'prerelease' | 'perf'
type ReleaseFeedPreflightResult = 'ready' | 'not-available'
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
@@ -44,9 +46,9 @@ let currentStatus: UpdateStatus = { state: 'idle' }
let userInitiatedCheck = false
let onBeforeQuitCleanup: (() => void | Promise<void>) | null = null
let autoUpdaterInitialized = false
// Why: Shift-clicking "Check for Updates" opts the user into the RC release
// channel for the rest of this process. The generic feed still gets pinned to
// a concrete tag on every check so cancelled RCs without manifests are skipped.
// Why: modifier-clicking "Check for Updates" can target prerelease manifests.
// The generic feed still gets pinned to a concrete tag on every check so
// cancelled prereleases without manifests are skipped.
let includePrereleaseActive = false
let availableVersion: string | null = null
let availableReleaseUrl: string | null = null
@@ -69,7 +71,7 @@ let activeUpdateCheckAttemptId: number | null = null
let activeUpdateCheckLaunchAttemptId: number | null = null
let activeUpdateCheckEventAttemptId: number | null = null
let updateAvailableEventPendingAttemptId: number | null = null
let pendingPrereleaseUserInitiatedCheckAfterInFlight = false
let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null
let activeUpdateNudgeId: string | null = null
let awaitingNudgeCheckOutcome = false
let nudgeCheckInFlight = false
@@ -156,8 +158,9 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus {
}
function sendStatus(status: UpdateStatus): void {
const shouldLaunchPendingPrereleaseCheck =
pendingPrereleaseUserInitiatedCheckAfterInFlight &&
const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight
const shouldLaunchPendingUserInitiatedCheck =
pendingUserInitiatedCheckVariant !== null &&
(status.state === 'idle' ||
status.state === 'not-available' ||
status.state === 'available' ||
@@ -225,8 +228,8 @@ function sendStatus(status: UpdateStatus): void {
) {
downloadInFlight = false
}
if (shouldLaunchPendingPrereleaseCheck) {
launchPendingPrereleaseUserInitiatedCheckAfterInFlight()
if (shouldLaunchPendingUserInitiatedCheck) {
launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant)
return
}
if (statusesEqual(currentStatus, decoratedStatus)) {
@@ -236,16 +239,37 @@ function sendStatus(status: UpdateStatus): void {
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
}
function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void {
pendingPrereleaseUserInitiatedCheckAfterInFlight = false
function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions {
switch (variant) {
case 'perf':
return { includePrerelease: true, includePerfPrerelease: true }
case 'prerelease':
return { includePrerelease: true }
case 'default':
return { includePrerelease: false }
}
}
function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant {
if (options?.includePerfPrerelease) {
return 'perf'
}
if (options?.includePrerelease) {
return 'prerelease'
}
return 'default'
}
function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void {
pendingUserInitiatedCheckAfterInFlight = null
setTimeout(() => {
// Why: electron-updater clears its in-flight promise after emitting the
// terminal event. Deferring one tick lets the queued RC check start fresh
// instead of being deduped into the just-finished stable check.
// terminal event. Deferring one tick lets the queued modifier check start
// fresh instead of being deduped into the just-finished stable check.
if (currentStatus.state === 'checking') {
currentStatus = { state: 'idle' }
}
checkForUpdatesFromMenu({ includePrerelease: true })
checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant))
}, 0)
}
@@ -769,7 +793,9 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v
)
}
async function pinDefaultReleaseFeed(): Promise<void> {
async function pinDefaultReleaseFeed(
variant: UpdateCheckVariant = 'default'
): Promise<ReleaseFeedPreflightResult> {
const autoUpdater = getAutoUpdater()
// Why: the /releases/latest/download/ redirect can move between the update
// check and the later manual download click. Pinning to the concrete tag
@@ -778,12 +804,15 @@ async function pinDefaultReleaseFeed(): Promise<void> {
// Prerelease users still need any-channel resolution so they can move to a
// newer RC or the next stable. Stable users should only resolve stable tags.
const currentVersion = app.getVersion()
const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion)
const isPerfCheck = variant === 'perf'
const includePrerelease =
isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion)
const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness(
currentVersion,
includePrerelease ? 2 : 1,
{
includePrerelease
includePrerelease,
...(isPerfCheck ? { releaseFilter: 'perf' as const } : {})
}
)
const newerTag = releaseTagsResult.tags[0] ?? null
@@ -814,6 +843,7 @@ async function pinDefaultReleaseFeed(): Promise<void> {
`[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
)
autoUpdater.setFeedURL({ provider: 'generic', url })
return 'ready'
} else if (releaseTagsResult.state === 'not-ready') {
clearPrereleaseFallbackContext()
if (releaseTagsResult.lastGoodTag) {
@@ -825,13 +855,23 @@ async function pinDefaultReleaseFeed(): Promise<void> {
)
publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag }
autoUpdater.setFeedURL({ provider: 'generic', url })
return
return 'ready'
}
clearPublishingWindowLastGoodCheck()
console.info(
`[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing`
)
throw new Error('Latest release assets are still publishing')
} else if (isPerfCheck) {
clearPrereleaseFallbackContext()
clearPublishingWindowLastGoodCheck()
if (releaseTagsResult.state === 'no-newer') {
console.info(
`[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}`
)
return 'not-available'
}
throw new Error('Could not resolve perf update feed')
} else {
clearPrereleaseFallbackContext()
clearPublishingWindowLastGoodCheck()
@@ -840,6 +880,7 @@ async function pinDefaultReleaseFeed(): Promise<void> {
`[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
)
autoUpdater.setFeedURL({ provider: 'generic', url })
return 'ready'
}
}
@@ -972,6 +1013,10 @@ export function checkForUpdates(): void {
})
}
function enablePrereleaseManifestChecks(): void {
getAutoUpdater().allowPrerelease = true
}
function enableIncludePrerelease(): void {
if (includePrereleaseActive) {
return
@@ -980,20 +1025,26 @@ function enableIncludePrerelease(): void {
// accept a prerelease manifest for users who intentionally Shift-clicked.
// We keep using the manifest-probed generic feed instead of the native
// GitHub provider because cancelled RC releases can appear without assets.
getAutoUpdater().allowPrerelease = true
enablePrereleaseManifestChecks()
includePrereleaseActive = true
}
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
if (!app.isPackaged || is.dev) {
sendStatus({ state: 'not-available', userInitiated: true })
return
}
if (options?.includePrerelease) {
const checkVariant = getUpdateCheckVariant(options)
if (checkVariant === 'prerelease') {
clearPrereleaseFallbackContext()
enableIncludePrerelease()
} else if (checkVariant === 'perf') {
clearPrereleaseFallbackContext()
// Why: perf checks need prerelease manifests for this check, but must not
// opt future default/background checks into the RC channel.
enablePrereleaseManifestChecks()
}
const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking'
@@ -1009,10 +1060,10 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
if (checkAlreadyInFlight) {
backgroundCheckPromotedToUserInitiated = true
rearmActiveUpdateCheckStallTimer()
if (options?.includePrerelease) {
// Why: the in-flight check may have already pinned the stable feed.
// Queue a fresh RC check so Shift-click doesn't inherit a stable result.
pendingPrereleaseUserInitiatedCheckAfterInFlight = true
if (checkVariant !== 'default') {
// Why: the in-flight check may have already pinned the stable feed. Queue
// a fresh modifier check so it doesn't inherit a stale-channel result.
pendingUserInitiatedCheckAfterInFlight = checkVariant
}
return
}
@@ -1026,9 +1077,26 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
markUpdateCheckLaunched(attemptId)
return autoUpdater.checkForUpdates()
}
const run = pinDefaultReleaseFeed().then(launch)
const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => {
if (preflightResult === 'not-available') {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return false
}
userInitiatedCheck = false
finishActiveUpdateCheckAttempt()
recordCompletedUpdateCheck()
sendStatus({ state: 'not-available', userInitiated: true })
return false
}
return launch()
})
void Promise.resolve(run)
.then(() => handleSettledUpdateCheckPromise(attemptId))
.then((launchResult) => {
if (launchResult === false) {
return
}
handleSettledUpdateCheckPromise(attemptId)
})
.catch((err) => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
@@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto'
import { app, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types'
import type {
CreateWorktreeResult,
UpdateCheckOptions,
WorktreeStartupLaunch
} from '../../shared/types'
import { registerRepoHandlers } from '../ipc/repos'
import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup'
@@ -448,7 +452,7 @@ export function registerUpdaterHandlers(_store: Store): void {
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
ipcMain.handle('updater:getVersion', () => app.getVersion())
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => {
ipcMain.handle('updater:check', (_event, options?: UpdateCheckOptions) => {
ensureAutoUpdaterConfigured()
return checkForUpdatesFromMenu(options)
})
+49 -6
View File
@@ -1,9 +1,12 @@
import { isFolderRepo } from '../shared/repo-kind'
import type { Repo } from '../shared/types'
import { hasLocalCommitObject } from './git/commit-object-ref'
import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref'
import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe'
import { getDefaultBaseRef } from './git/repo'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote'
import { resolveWorktreeCreateBase } from './worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref'
type RemoteTrackingBaseForPrefetch = {
remote: string
@@ -17,6 +20,7 @@ type WorktreeCreateBasePrefetchRuntime = {
repoPath: string,
baseBranch: string
) => Promise<RemoteTrackingBaseForPrefetch | null>
hasRemoteTrackingRef: (repoPath: string, base: RemoteTrackingBaseForPrefetch) => Promise<boolean>
getOrStartRemoteTrackingBaseRefresh: (
repoPath: string,
base: RemoteTrackingBaseForPrefetch
@@ -24,23 +28,62 @@ type WorktreeCreateBasePrefetchRuntime = {
fetchRemoteWithCache: (repoPath: string, remote: string) => Promise<void>
}
async function hasLocalWorktreeBaseRef(repoPath: string, baseRef: string): Promise<boolean> {
const refExists = (qualifiedRef: string) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef)
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
if (resolvedBaseRef !== baseRef) {
return true
}
if (baseRef.startsWith('refs/')) {
return refExists(baseRef)
}
return hasLocalCommitObject(repoPath, baseRef)
}
async function prefetchLocalWorktreeCreateBase(
repo: Repo,
baseBranch: string | undefined,
runtime: WorktreeCreateBasePrefetchRuntime
): Promise<void> {
const resolvedBaseBranch = baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
const resolvedBaseBranch = await resolveWorktreeCreateBase({
requestedBaseBranch: baseBranch,
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path),
isBaseUsable: async (baseBranchCandidate) => {
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
repo.path,
baseBranchCandidate
)
if (remoteTrackingBase) {
if (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) {
return true
}
return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate)
}
return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate)
}
})
if (!resolvedBaseBranch) {
return
}
if (await hasLocalCommitObject(repo.path, resolvedBaseBranch)) {
// Why: hosted-review start points can be verified commit SHAs; a broad
// remote fetch cannot make an already-local object fresher.
if (
isFullGitObjectId(resolvedBaseBranch) &&
(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch))
) {
return
}
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, resolvedBaseBranch)
if (remoteTrackingBase) {
await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
if (
(await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) ||
!(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch))
) {
await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
return
}
}
if (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) {
// Why: hosted-review start points and local branch bases are already local; a broad remote fetch cannot make them fresher.
return
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveWorktreeCreateBase } from './worktree-create-base'
describe('resolveWorktreeCreateBase', () => {
it('falls back from a stale persisted base to the detected default', async () => {
const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main')
const isBaseUsable = vi.fn().mockResolvedValue(false)
await expect(
resolveWorktreeCreateBase({
repoWorktreeBaseRef: 'origin/master',
resolveDefaultBaseRef,
isBaseUsable
})
).resolves.toBe('origin/main')
expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1)
expect(isBaseUsable).toHaveBeenCalledWith('origin/master')
})
it('returns an explicit base without probing defaults or usability', async () => {
const resolveDefaultBaseRef = vi.fn()
const isBaseUsable = vi.fn()
await expect(
resolveWorktreeCreateBase({
requestedBaseBranch: 'origin/master',
repoWorktreeBaseRef: 'origin/main',
resolveDefaultBaseRef,
isBaseUsable
})
).resolves.toBe('origin/master')
expect(resolveDefaultBaseRef).not.toHaveBeenCalled()
expect(isBaseUsable).not.toHaveBeenCalled()
})
it('keeps a usable persisted base when it is still valid', async () => {
const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main')
const isBaseUsable = vi.fn().mockResolvedValue(true)
await expect(
resolveWorktreeCreateBase({
repoWorktreeBaseRef: 'origin/master',
resolveDefaultBaseRef,
isBaseUsable
})
).resolves.toBe('origin/master')
expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1)
expect(isBaseUsable).toHaveBeenCalledWith('origin/master')
})
})
+29
View File
@@ -0,0 +1,29 @@
type ResolveWorktreeCreateBaseArgs = {
requestedBaseBranch?: string
repoWorktreeBaseRef?: string | null
resolveDefaultBaseRef: () => Promise<string | null>
isBaseUsable: (baseBranch: string) => Promise<boolean>
}
export async function resolveWorktreeCreateBase(
args: ResolveWorktreeCreateBaseArgs
): Promise<string | null> {
if (args.requestedBaseBranch) {
return args.requestedBaseBranch
}
const defaultBaseRef = await args.resolveDefaultBaseRef()
const repoWorktreeBaseRef = args.repoWorktreeBaseRef
if (!repoWorktreeBaseRef) {
return defaultBaseRef
}
if (!defaultBaseRef) {
return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : null
}
// Resolving the default already proved matching persisted refs exist.
if (repoWorktreeBaseRef === defaultBaseRef) {
return repoWorktreeBaseRef
}
// Stale persisted refs fall back to the detected default; usable custom refs stay authoritative.
return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : defaultBaseRef
}

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