diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index db1970ae5ed..9b04d3205fc 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -6,9 +6,9 @@ name: Homebrew Cask Bump # - workflow_dispatch → manual backfill for a specific tag # # The job computes the sha256 of orca-macos-arm64.dmg + orca-macos-x64.dmg -# from the release, templates Casks/orca.rb in this repo with the new -# version and hashes, then PRs the result into stablyai/homebrew-orca. It -# does NOT run for -rc.* tags: the stable cask should only track GA. +# from the release, templates the channel-specific cask in this repo with the +# new version and hashes, then PRs the result into stablyai/homebrew-orca. +# Stable tags update Casks/orca.rb; RC tags update Casks/orca@rc.rb. # # Why no `release.published` trigger: other products shipped from this repo # (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket @@ -35,14 +35,10 @@ on: jobs: bump-cask: runs-on: ubuntu-latest - # Why: defense in depth. The desktop release pipeline already refuses - # to invoke this workflow for `-rc.*` tags, but a manual backfill could - # still pass one in — reject it here so the stable cask can never point - # at an RC. Also requires the desktop tag shape `v...` so a - # mis-typed mobile tag like `mobile-v0.0.1` is rejected up front. + # Why: only desktop tags have the macOS DMGs this workflow downloads. + # A mobile tag such as `mobile-v0.0.1` must never rewrite either cask. if: > inputs.tag != '' && - !contains(inputs.tag, '-rc.') && startsWith(inputs.tag, 'v') permissions: contents: read @@ -66,6 +62,35 @@ jobs: echo "tag=$tag" >>"$GITHUB_OUTPUT" echo "version=$version" >>"$GITHUB_OUTPUT" + - name: Resolve cask target + id: cask + env: + TAG: ${{ steps.tag.outputs.tag }} + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + token="orca@rc" + branch_token="orca-rc" + elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + token="orca" + branch_token="orca" + else + echo "::error::Unsupported desktop tag shape: $TAG" >&2 + exit 1 + fi + + path="Casks/${token}.rb" + if [[ ! -f "$path" ]]; then + echo "::error::Missing cask template: $path" >&2 + exit 1 + fi + + echo "token=$token" >>"$GITHUB_OUTPUT" + echo "path=$path" >>"$GITHUB_OUTPUT" + echo "branch=bump/${branch_token}-${VERSION}" >>"$GITHUB_OUTPUT" + echo "title=${token} ${VERSION}" >>"$GITHUB_OUTPUT" + - name: Download DMGs and compute sha256 id: hash env: @@ -89,11 +114,12 @@ jobs: VERSION: ${{ steps.tag.outputs.version }} ARM_SHA: ${{ steps.hash.outputs.arm }} INTEL_SHA: ${{ steps.hash.outputs.intel }} + CASK_PATH: ${{ steps.cask.outputs.path }} run: | set -euo pipefail python3 - <<'PY' import os, re, pathlib - p = pathlib.Path("Casks/orca.rb") + p = pathlib.Path(os.environ["CASK_PATH"]) src = p.read_text() src = re.sub(r'version "[^"]+"', f'version "{os.environ["VERSION"]}"', src, count=1) @@ -132,10 +158,13 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} VERSION: ${{ steps.tag.outputs.version }} TAG: ${{ steps.tag.outputs.tag }} + CASK_PATH: ${{ steps.cask.outputs.path }} + BRANCH: ${{ steps.cask.outputs.branch }} + TITLE: ${{ steps.cask.outputs.title }} run: | set -euo pipefail - mkdir -p tap/Casks - cp Casks/orca.rb tap/Casks/orca.rb + mkdir -p "tap/$(dirname "$CASK_PATH")" + cp "$CASK_PATH" "tap/$CASK_PATH" cd tap # Why: commit author must match the buf0-bot app so commits are @@ -148,20 +177,20 @@ jobs: exit 0 fi - branch="bump/orca-$VERSION" + branch="$BRANCH" git checkout -b "$branch" - git add Casks/orca.rb - git commit -m "orca $VERSION" + git add "$CASK_PATH" + git commit -m "$TITLE" git push -u origin "$branch" --force - # Why: prefer --fill so the PR body matches the commit; fall back - # to a brief body if the PR already exists for this branch. + # Why: repeated backfills can race an existing PR branch, so edit + # the PR title if creation reports that one already exists. gh pr create \ - --title "orca $VERSION" \ - --body "Automated bump from stablyai/orca release $TAG." \ + --title "$TITLE" \ + --body "Automated bump of $CASK_PATH from stablyai/orca release $TAG." \ --base main \ --head "$branch" \ - || gh pr edit "$branch" --title "orca $VERSION" + || gh pr edit "$branch" --title "$TITLE" # Why: squash-merge immediately rather than --auto. The tap has no # required checks or branch protection, and `gh pr merge --auto` diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index d115007a09d..ad9cd01a597 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -62,6 +62,7 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }} + latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }} steps: - name: Checkout ref uses: actions/checkout@v6 @@ -548,9 +549,11 @@ jobs: # Why: `pnpm build:release` verifies the Linux computer-use provider by # importing AT-SPI bindings, which are runtime package deps but are not # present on stock GitHub Ubuntu release runners. + # Why: `rpm` is needed by electron-builder's fpm backend to produce the + # .rpm artifact. Stock Ubuntu runners do not ship it. - name: Install Linux computer-use provider dependencies if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool + run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool rpm - name: Verify macOS signing environment if: matrix.platform == 'mac' @@ -764,11 +767,22 @@ jobs: --prerelease="$prerelease" \ --repo "$GITHUB_REPOSITORY" + homebrew-bump-published-rc-draft: + needs: + - cut + # Why: publish-complete-draft-releases can expose a recovered RC without + # running the build/publish jobs; still advance the RC cask to that tag. + if: ${{ needs.cut.outputs.latest_published_rc_tag != '' }} + uses: ./.github/workflows/homebrew-bump.yml + with: + tag: ${{ needs.cut.outputs.latest_published_rc_tag }} + secrets: inherit + homebrew-bump: needs: - cut - publish-release - if: ${{ !contains(needs.cut.outputs.tag, '-rc.') }} + if: ${{ needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }} uses: ./.github/workflows/homebrew-bump.yml with: tag: ${{ needs.cut.outputs.tag }} diff --git a/.gitignore b/.gitignore index 1078c5105dd..11793570b6b 100644 --- a/.gitignore +++ b/.gitignore @@ -80,7 +80,10 @@ docs/** !docs/readme/** !docs/reference/ !docs/reference/** +# Keep generated React perf scan ledgers local; durable docs still use docs/reference. +docs/reference/react-performance-audit.md !docs/STYLEGUIDE.md +!docs/mobile-terminal-shortcut-bar.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/Casks/orca.rb b/Casks/orca.rb index 43a93af5df2..53a98c54c02 100644 --- a/Casks/orca.rb +++ b/Casks/orca.rb @@ -22,7 +22,8 @@ cask "orca" do # becomes a no-op unless the user passes --greedy, and brew's version # metadata stays aligned with whatever the app has swapped itself to. auto_updates true - depends_on macos: ">= :big_sur" + conflicts_with cask: "orca@rc" + depends_on macos: :big_sur app "Orca.app" diff --git a/Casks/orca@rc.rb b/Casks/orca@rc.rb new file mode 100644 index 00000000000..f3782f7db2d --- /dev/null +++ b/Casks/orca@rc.rb @@ -0,0 +1,50 @@ +cask "orca@rc" do + arch arm: "arm64", intel: "x64" + + version "1.4.36-rc.3" + sha256 arm: "563b6b14323fc9d5489299c82442d514bc12cabffc9d06d3964ed572af4b3955", + intel: "457088c7021f07de1a419197f7b2bd00092741ad4727d4fef3d86af38a6831e7" + + url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg", + verified: "github.com/stablyai/orca/" + name "Orca RC" + desc "IDE for orchestrating AI coding agents across terminals and worktrees" + homepage "https://onorca.dev/" + + livecheck do + url "https://github.com/stablyai/orca" + regex(/^v?(\d+(?:\.\d+)+-rc\.\d+)$/i) + strategy :github_releases do |json, regex| + json.map do |release| + next if release["draft"] + next unless release["prerelease"] + + match = release["tag_name"]&.match(regex) + next if match.blank? + + match[1] + end + end + end + + # Why: RC installs should follow Orca's prerelease-aware updater instead of + # waiting for Homebrew metadata churn between frequent release candidates. + auto_updates true + conflicts_with cask: "orca" + depends_on macos: :big_sur + + app "Orca.app" + + # Why: Orca writes user data under ~/.orca (worktrees, agent state) and + # Electron's standard userData directories. Zap removes everything the app + # creates during normal use so `brew uninstall --zap` is a clean slate. + zap trash: [ + "~/.orca", + "~/Library/Application Support/Orca", + "~/Library/Caches/com.stablyai.orca", + "~/Library/Caches/com.stablyai.orca.ShipIt", + "~/Library/HTTPStorages/com.stablyai.orca", + "~/Library/Preferences/com.stablyai.orca.plist", + "~/Library/Saved Application State/com.stablyai.orca.savedState", + ] +end diff --git a/DEVELOPING.md b/DEVELOPING.md new file mode 100644 index 00000000000..183a8d1be98 --- /dev/null +++ b/DEVELOPING.md @@ -0,0 +1,19 @@ +# Developing + +## Release Channels + +The public Homebrew cask tracks stable desktop releases: + +```bash +brew install --cask stablyai/orca/orca +``` + +Release candidates use a separate cask token: + +```bash +brew install --cask stablyai/orca/orca@rc +``` + +The two casks conflict because both install `Orca.app`. Switch channels with a +normal `brew uninstall --cask` followed by the install for the other channel. +Do not use `--zap` unless you intentionally want to remove local Orca state. diff --git a/README.md b/README.md index 3bedf42646d..0549110e619 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Run Claude Code, OpenClaude, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
Available for macOS, Windows, and Linux.

@@ -30,6 +30,7 @@ Orca supports any CLI agent (*not just this list*).

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -62,7 +63,7 @@ Orca supports any CLI agent (*not just this list*). ## Features -- **No login required** — Bring your own Claude Code, Codex, Grok, or Antigravity subscription. +- **No login required** — Bring your own Claude Code, OpenClaude, Codex, Grok, or Antigravity subscription. - **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly. - **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance. - **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca. diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 5177a7a7a6e..b4017a7f9f9 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -200,8 +200,8 @@ module.exports = { extraResources: [ relayExtraResource, { - from: 'resources/linux/bin/orca', - to: 'bin/orca' + from: 'resources/linux/bin/orca-ide', + to: 'bin/orca-ide' }, { from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}', @@ -213,7 +213,7 @@ module.exports = { }, featureWallResources ], - target: ['AppImage', 'deb'], + target: ['AppImage', 'deb', 'rpm'], maintainer: 'stablyai', category: 'Utility' }, @@ -225,6 +225,11 @@ module.exports = { artifactName: 'orca-ide_${version}_${arch}.${ext}', depends: ['python3', 'python3-gi', 'gir1.2-atspi-2.0', 'at-spi2-core', 'xdotool', 'xclip'] }, + rpm: { + packageName: 'orca-ide', + artifactName: 'orca-ide-${version}.${arch}.${ext}', + depends: ['python3', 'python3-gobject', 'at-spi2-core', 'xdotool', 'xclip'] + }, // Why: must be true so that electron-builder rebuilds native modules // (node-pty) for each target architecture when producing dual-arch macOS // builds (x64 + arm64). With npmRebuild disabled, CI on an arm64 runner diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 453e9f6325d..4e4938a3585 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -8,4 +8,14 @@ describe('electron-builder config', () => { it('uses the multi-size icon source for Linux packages', () => { expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns') }) + + it('builds RPMs without changing existing Linux artifact names', () => { + expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm']) + expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}') + expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}') + expect(electronBuilderConfig.rpm).toMatchObject({ + packageName: 'orca-ide', + artifactName: 'orca-ide-${version}.${arch}.${ext}' + }) + }) }) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 99275d6385f..98f822f06c6 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -75,6 +75,39 @@ describe('Electron runtime package contract', () => { expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') }) + it('bumps separate Homebrew casks for stable and RC desktop tags', () => { + const releaseWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + ) + const homebrewWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/homebrew-bump.yml'), 'utf8') + ) + + expect(releaseWorkflow.jobs['homebrew-bump'].if).toContain( + "startsWith(needs.cut.outputs.tag, 'v')" + ) + expect(releaseWorkflow.jobs['homebrew-bump'].if).not.toContain("-rc.") + expect(releaseWorkflow.jobs['homebrew-bump-published-rc-draft'].with.tag).toBe( + '${{ needs.cut.outputs.latest_published_rc_tag }}' + ) + + const resolveCaskStep = homebrewWorkflow.jobs['bump-cask'].steps.find( + (step) => step.name === 'Resolve cask target' + ) + const renderStep = homebrewWorkflow.jobs['bump-cask'].steps.find( + (step) => step.name === 'Render updated cask file' + ) + const copyStep = homebrewWorkflow.jobs['bump-cask'].steps.find( + (step) => step.name === 'Copy cask into tap and open PR' + ) + + expect(resolveCaskStep.run).toContain('token="orca@rc"') + expect(resolveCaskStep.run).toContain('token="orca"') + expect(renderStep.env.CASK_PATH).toBe('${{ steps.cask.outputs.path }}') + expect(copyStep.run).toContain('cp "$CASK_PATH" "tap/$CASK_PATH"') + expect(copyStep.run).toContain('git add "$CASK_PATH"') + }) + it('installs the Electron package binary in PR checks without changing native module ABI', () => { const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') const parsedWorkflow = parse(prWorkflow) diff --git a/config/scripts/publish-complete-draft-releases.mjs b/config/scripts/publish-complete-draft-releases.mjs index 68af1d87c60..fade0b5e357 100644 --- a/config/scripts/publish-complete-draft-releases.mjs +++ b/config/scripts/publish-complete-draft-releases.mjs @@ -112,6 +112,7 @@ export function writeGithubOutputs({ published, skipped }, outputPath = process. `${[ `published_count=${published.length}`, `skipped_count=${skipped.length}`, + `latest_published_tag=${published.at(-1) ?? ''}`, `published_tags=${published.join(',')}`, `skipped_tags=${skipped.map((item) => item.tag).join(',')}` ].join('\n')}\n` diff --git a/config/scripts/publish-complete-draft-releases.test.mjs b/config/scripts/publish-complete-draft-releases.test.mjs index 589f5475b50..098d637389c 100644 --- a/config/scripts/publish-complete-draft-releases.test.mjs +++ b/config/scripts/publish-complete-draft-releases.test.mjs @@ -125,6 +125,7 @@ describe('writeGithubOutputs', () => { `${[ 'published_count=1', 'skipped_count=1', + 'latest_published_tag=v1.4.2-rc.7', 'published_tags=v1.4.2-rc.7', 'skipped_tags=v1.4.2-rc.8' ].join('\n')}\n` diff --git a/config/scripts/verify-release-required-assets.mjs b/config/scripts/verify-release-required-assets.mjs index 12dbdb53b34..5c78533b5af 100644 --- a/config/scripts/verify-release-required-assets.mjs +++ b/config/scripts/verify-release-required-assets.mjs @@ -12,6 +12,7 @@ export function getRequiredReleaseAssetNames(tag) { 'latest.yml', 'orca-linux.AppImage', `orca-ide_${version}_amd64.deb`, + `orca-ide-${version}.x86_64.rpm`, 'orca-windows-setup.exe', 'orca-windows-setup.exe.blockmap', `Orca-${version}-mac.zip`, diff --git a/config/scripts/verify-release-required-assets.test.mjs b/config/scripts/verify-release-required-assets.test.mjs index 06a853e11ac..1caac947dc7 100644 --- a/config/scripts/verify-release-required-assets.test.mjs +++ b/config/scripts/verify-release-required-assets.test.mjs @@ -44,6 +44,16 @@ describe('getRequiredReleaseAssetNames', () => { ]) ) }) + + it('includes the Linux RPM alongside the existing AppImage and deb names', () => { + expect(getRequiredReleaseAssetNames('v1.4.27')).toEqual( + expect.arrayContaining([ + 'orca-linux.AppImage', + 'orca-ide_1.4.27_amd64.deb', + 'orca-ide-1.4.27.x86_64.rpm' + ]) + ) + }) }) describe('extractManifestAssetNames', () => { diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 111ac7a86bf..e606895e682 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -6,6 +6,7 @@ "../src/main/agent-hooks/installer-utils.ts", "../src/main/agent-hooks/installer-utils-remote.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", + "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", @@ -21,6 +22,7 @@ "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", "../src/main/hermes/hook-service.ts", + "../src/main/openclaude/hook-service.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/win32-utils.ts" ], diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 108a8eacba2..06a2d45b803 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -14,7 +14,7 @@

El orquestador de IA para desarrolladores 100x.
- Ejecuta Claude Code, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
+ Ejecuta Claude Code, OpenClaude, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
Disponible para macOS, Windows y Linux.

@@ -32,6 +32,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -64,7 +65,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_). ## Características -- **Sin login** — Usa tu propia suscripción de Claude Code, Codex, Grok o Antigravity. +- **Sin login** — Usa tu propia suscripción de Claude Code, OpenClaude, Codex, Grok o Antigravity. - **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante. - **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos. - **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca. diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 2bfe4360fef..389facb6757 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -14,7 +14,7 @@

100x ビルダーのための AI オーケストレーター。
- Claude Code、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
+ Claude Code、OpenClaude、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
macOS、Windows、Linux で利用できます。

@@ -32,6 +32,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -64,7 +65,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス ## 機能 -- **ログイン不要** — お持ちの Claude Code、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。 +- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。 - **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。 - **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。 - **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。 diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 7e25bb80829..1c20d6ea5c5 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -14,7 +14,7 @@

100x 빌더를 위한 AI 오케스트레이터.
- Claude Code, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
+ Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
macOS, Windows, Linux에서 사용할 수 있습니다.

@@ -32,6 +32,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -64,7 +65,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정 ## 기능 -- **로그인 불필요** — 보유한 Claude Code, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요. +- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요. - **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다. - **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다. - **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index bf31b3bc0cd..386c3f47ddd 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -14,7 +14,7 @@

面向 100x 构建者的 AI 编排器。
- 跨仓库并排运行 Claude Code、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
+ 跨仓库并排运行 Claude Code、OpenClaude、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
支持 macOS、Windows 和 Linux。

@@ -32,6 +32,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。

Claude Code   + OpenClaude   Codex   Grok   Gemini   @@ -64,7 +65,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。 ## 特性 -- **无需登录** — 直接使用你自己的 Claude Code、Codex、Grok 或 Antigravity 订阅。 +- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。 - **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。 - **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。 - **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。 diff --git a/docs/reference/memory-leak-audit-final-comb.md b/docs/reference/memory-leak-audit-final-comb.md deleted file mode 100644 index 0608705f10a..00000000000 --- a/docs/reference/memory-leak-audit-final-comb.md +++ /dev/null @@ -1,61 +0,0 @@ -# Memory Leak Audit Final Comb - -Started: 2026-05-29 PDT - -Final tracker branch: `nwparker/mem-leak-final-comb-tracker`, based on `origin/main` at `869661cbba` after the final-comb fix PRs merged. - -Objective: run one more complete pass across the current Orca tree for missed listener, timer, observer, worker, socket, watcher, subscription, abort, and disposable leaks; open scoped PRs for confirmed stranglers; include risk level and merge low-risk PRs. - -## Codebase Inventory - -Tracked code files, from `git ls-files`: - -| Bucket | Files | Status | -| ----------------- | ----: | ------- | -| `src/renderer` | 1584 | Checked | -| `src/main` | 856 | Checked | -| `src/shared` | 245 | Checked | -| `mobile/src` | 100 | Checked | -| `tests/e2e` | 72 | Checked | -| `src/relay` | 65 | Checked | -| `src/cli` | 65 | Checked | -| `config/scripts` | 33 | Checked | -| `mobile/app` | 16 | Checked | -| `other-code` | 5 | Checked | -| `src/preload` | 6 | Checked | -| `mobile/packages` | 5 | Checked | -| Total | 3052 | Checked | - -## Scan Log - -- 2026-05-29: Created fresh final-comb branch from current `origin/main`. -- 2026-05-29: Fast-forwarded the final-comb branch as `origin/main` moved; final tracker is based on `869661cbba`. -- 2026-05-29: Counted 3341 broad risk-pattern hits across `src`, `mobile`, `tests`, `config`, and `native` on the final post-fix tree. -- 2026-05-29: Re-ran heuristic buckets after fixes: - - `addEventListener` without same-file `removeEventListener`: 37 remaining. Reviewed as React Native subscriptions with `.remove()`, injected WebView document-lifetime scripts, `{ once: true }` image load handlers, IPC abort-signal test listeners, singleton image-cache invalidation, owned pane-divider DOM, and test fixture listeners. - - `setInterval` without `clearInterval`: 1 remaining, a test comment string. - - `setTimeout` without same-file `clearTimeout`: 233 remaining. Reviewed remaining main-process hits as sleep helpers, intentional app relaunch/exit delays, socket idle timeout ownership, usage-scanner yield points, and startup force-exit behavior. Confirmed final-comb timer findings are fixed in PRs listed below. - - Observers without `disconnect`: 0. - - Workers without `terminate`: 0. - - Abort controllers without `abort`: 1 test-only controller passed through the abortable API under test. - - Watchers without close/unwatch: no production misses; remaining heuristic hits are provider type/comment references. -- 2026-05-29: Re-ran React effect scan and spot-checked candidates. Remaining hits had cleanup returns (`clearTimeout`, `clearInterval`, `removeEventListener`, IPC unsubscribe) or were short one-shot UI focus/open delays. - -## Findings - -| PR | Risk | Status | Finding | Resolution | -| -- | ---- | ------ | ------- | ---------- | -| [#3305](https://github.com/stablyai/orca/pull/3305) | LOW | Merged | Browser reload fallback timer retained the reload promise closure, webContents reference, and listeners until the 10s fallback fired even after `did-finish-load`/`did-fail-load`. | Clear the fallback timer on early settle, guard duplicate cleanup, and test with fake timers. | -| [#3306](https://github.com/stablyai/orca/pull/3306) | LOW | Merged | Native notification retention timers stayed scheduled after close/click cleanup, retaining notification closures until fallback expiry. | Clear notification fallback timers on release, detach native listeners, and cover dispatch/startup/accessibility notification paths. | -| [#3307](https://github.com/stablyai/orca/pull/3307) | LOW | Merged | Local-network permission UDP prompt kept its 1s fallback timer and socket error listener after the send callback settled first. | Clear the fallback timer, remove the error listener, and test the `developerPermissions:request` local-network path. | - -No higher-risk findings remained after the final pass, so no second higher-risk mitigation pass was needed. - -## Validation - -- `pnpm vitest run --config config/vitest.config.ts src/main/browser/agent-browser-bridge.test.ts` -- `pnpm vitest run --config config/vitest.config.ts src/main/browser/agent-browser-bridge.test.ts src/main/ipc/notifications.test.ts src/main/computer/permissions.test.ts src/main/ipc/developer-permissions.test.ts` -- `pnpm vitest run --config config/vitest.config.ts src/main/ipc/notifications.test.ts src/main/computer/permissions.test.ts` -- `pnpm vitest run --config config/vitest.config.ts src/main/ipc/developer-permissions.test.ts` -- `pnpm exec oxlint src/main/browser/agent-browser-bridge.ts src/main/browser/agent-browser-bridge.test.ts src/main/ipc/notifications.ts src/main/ipc/notifications.test.ts src/main/computer/permissions.ts src/main/computer/permissions.test.ts src/main/ipc/developer-permissions.ts src/main/ipc/developer-permissions.test.ts` -- `pnpm run typecheck:node` diff --git a/docs/reference/react-performance-audit.md b/docs/reference/react-performance-audit.md deleted file mode 100644 index 89109c23887..00000000000 --- a/docs/reference/react-performance-audit.md +++ /dev/null @@ -1,355 +0,0 @@ -# React Performance Audit - -Status: in progress -Started: 2026-05-29 -Base commit: `b7fe967780` (`origin/main`, release v1.4.35) - -## Goal - -Scan the full React surface for `$perf` and `$react-useeffect` issues, keep coverage evidence here, and land the fixes as many small PRs with explicit merge-risk notes. - -This document is the audit ledger. A section is not considered fully scanned until it has: - -1. File inventory checked from the current worktree. -2. `useEffect` / `useLayoutEffect` / `useInsertionEffect` sites classified. -3. Perf-sensitive patterns checked: timers, subscriptions, observers, `JSON.stringify`, storage writes, polling, broad store subscriptions, large list rendering, and render-time derived state. -4. Each suspicious site dispositioned as "no change", "needs PR", or "covered by PR". -5. Merge risk recorded for every PR candidate. - -## Scope - -React hook call scan uses all repo `*.ts` and `*.tsx` files, then narrows to actual AST hook calls. The React Effect surface found on 2026-05-29 is: - -- `src/renderer/src/**` -- `mobile/app/**` -- `mobile/src/**` -- `mobile/packages/expo-two-way-audio/src/hooks.ts` - -Comment-only mentions outside those paths were ignored. - -Initial inventory: - -| Metric | Count | -| --------------------------------------------------- | ----: | -| Repo `*.ts` / `*.tsx` files | 3,011 | -| Files with real Effect hook calls | 285 | -| Effect hook call sites | 970 | -| `useLayoutEffect` / `useInsertionEffect` call sites | 44 | -| Empty dependency arrays | 111 | -| Effects with cleanup returns | 472 | -| Effects with subscription/listener/observer shape | 170 | -| Effects with timer or animation-frame shape | 128 | -| Effects with `JSON.stringify` shape | 2 | -| Set-state-shaped Effects needing manual review | 270 | -| `useMemo` call sites in React scope | 633 | -| `useCallback` call sites in React scope | 1,415 | -| `useSyncExternalStore` call sites in React scope | 8 | - -## Coverage Ledger - -Current count after low-risk PRs #3038, #3041, #3042, #3044, #3051, #3052, #3053, #3054, #3055, #3056, #3058, #3059, #3060, #3062, #3063, #3064, #3065, #3066, #3067, #3068, #3069, #3083, #3087, #3091, #3100, #3104, #3122, #3160, #3164, #3166, #3223, #3244, #3271, #3273, #3294, #3296, #3298, and #3300, plus upstream tab-entry changes #3011 and #3026: 912 Effect hook call sites. - -Open medium-risk PRs #3070, #3073, #3077, #3089, #3093, #3102, #3108, #3110, #3112, #3118, #3124, #3126, #3128, #3130, #3132, #3134, #3142, #3144, #3146, #3148, #3150, #3152, #3154, #3170, #3176, #3184, #3186, #3188, #3192, #3195, #3198, #3200, #3202, #3206, #3218, #3220, #3226, #3228, #3234, #3240, #3242, #3247, #3257, #3269, #3275, #3277, #3283, #3289, #3292, #3303, #3311, #3314, #3316, #3318, and #3322 each project to 911 Effect hook call sites on the current merged baseline; open medium-risk PRs #3097, #3106, #3116, #3120, #3136, #3138, #3180, #3210, #3214, #3238, #3255, #3261, #3281, #3285, and #3287 each project to 910; open medium-risk PRs #3114, #3168, and #3259 each project to 909; open medium-risk PRs #3095, #3140, and #3230 each project to 908; open medium-risk PR #3079 projects to 902; open high-risk PRs #3157, #3212, and #3253 each project to 911; open high-risk PR #3267 projects to 910; open high-risk PR #3075 projects to 908; open high-risk PR #3250 projects to 907; and open high-risk PR #3081 projects to 904. These are not counted in the merged baseline until reviewed and merged. - -| Area | Files / signal | Scan status | Notes | -| ------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Renderer app shell | `src/renderer/src/App.tsx`, root components | Inventory complete, manual review in progress | Confirmation dialog active request mirror covered by #3091; onboarding settings detour reset covered by #3122; quick workspace agent selection repair covered by #3148; Cmd+J selection repair covered by #3164; smart workspace command highlight covered by #3192; App lazy-modal mount bookkeeping covered by #3220; shared reduced-motion media-query hook covered by #3287; tab-entry selected action reset covered by #3311. Continue checking global listeners, beforeunload, media-query, and active-tab repair. | -| Terminal / PTY | `components/Terminal.tsx`, `components/terminal-pane/**`, `components/terminal/**` | Inventory complete, manual review pending | Mobile driver overlay collapse repair covered by #3170; floating workspace first-open bounds normalization covered by #3269; unused legacy terminal shell/shortcut/hook files deleted by #3271; terminal tab rename focus covered by #3314 without PTY protocol changes. High-risk area: xterm lifecycle, scrollback, remote/mobile parity, focus, WebGL, resize. | -| Renderer mobile page | `components/mobile/**` | Inventory complete, manual review in progress | Mobile pairing page stage repair covered by #3180. Continue checking QR generation, mobile-page IPC, and paired-device polling. | -| Browser pane | `components/browser-pane/**` | Inventory complete, manual review in progress | Highest Effect density: 62 sites in `BrowserPane.tsx`. Browser ref mirrors covered by #3081; BrowserFind debounced-query state covered by #3275. Continue with driver sync, address bar derived state, remaining find state, webview lifetime. | -| Editor / markdown / Monaco | `components/editor/**` | Inventory complete, manual review in progress | Untitled rename reset covered by #3083; copy feedback timers covered by #3097; combined diff local state repairs covered by #3120; combined diff note-copy timer covered by #3218; DiffViewer first-diff auto-scroll reset covered by #3257; editor path-copy timer covered by #3277; PDF find open/reset Effect covered by #3289; editor tab rename focus covered by #3303; rich markdown link bubble focus covered by #3316; image preview source derivation covered by #3318; markdown preview search focus covered by #3322. Check editor model cleanup, preview scroll restore, search debounce, generated decorations. | -| Sidebar / worktrees | `components/sidebar/**` | Inventory complete, manual review in progress | Orca hook trust checkbox reset covered by #3089; delete dialog one-shot checkbox reset covered by #3134; Add Repo clone destination auto-fill covered by #3150; sidebar live-width mirror covered by #3160; worktree selection pruning covered by #3166; SSH reconnect prompts covered by #3267; nested repo indeterminate checkbox covered by #3294; workspace title rename focus covered by #3296; workspace title editing notification covered by #3298; worktree metadata notes resize covered by #3300. Continue with drag/drop global listeners and kanban pointer flows. | -| Workspace cleanup | `components/workspace-cleanup/**` | Inventory complete, manual review in progress | Active cleanup-view repair covered by #3195. Continue checking destructive removal, repo filtering, scan refresh, and SSH/disconnected blockers. | -| Right sidebar / source control | `components/right-sidebar/**` | Inventory complete, manual review in progress | Source-control selection ref mirrors covered by #3075; Create PR dialog submit reset covered by #3184; commit-failure dialog identity repair covered by #3202; diff-comments clear confirmation repair covered by #3206; diff-notes copy feedback timers covered by #3210; selection stale-key pruning covered by #3212. Continue checking polling, PR checks, file explorer watch/reveal, and source-control local resets. Git provider compatibility required. | -| Settings | `components/settings/**` | Inventory complete, manual review in progress | Repository hook local editor visibility covered by #3093; lazy section mounted-state bookkeeping covered by #3128; browser home/Kagi draft sync covered by #3136; general auto-save/Open In draft sync covered by #3138; Source Control AI instruction drafts covered by #3140; notification volume draft sync covered by #3142; repo Source Control AI override draft sync covered by #3144; Quick Commands add intent covered by #3146; Mobile QR polling count capture covered by #3152; integration preflight status derivation covered by #3154; SSH reset-relay dialog cleanup covered by #3157; diagnostics bundle cleanup ref mirror covered by #3176. Continue with draft mirrors while keeping SSH and cross-platform settings behavior intact. | -| Automations | `components/automations/**` | Inventory complete, manual review in progress | Combobox open-focus Effects covered by #3100; run-history selection repair covered by #3104; external run-table selection repair covered by #3102; detail-page selection resets covered by #3106; create-from branch search reset covered by #3132. Continue with draft defaulting, polling, and completion detection. | -| Issue, PR, task pages | `TaskPage.tsx`, `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `GitLabItemDialog.tsx`, Linear components | Inventory complete, manual review in progress | Linear estimate input sync covered by #3108; Linear sub-issue list sync covered by #3110; GitHub project date draft sync covered by #3112; GitHub reviewer-picker active index covered by #3114; GitHub project column state covered by #3116; GitHub Project visible-table cache covered by #3186; GitHub Project dialog state covered by #3214; GitLab comment draft reset covered by #3124; Linear title/description draft sync covered by #3130; GitHub assignee dirty-flag reset covered by #3255; GitHub reviewer-request local sync covered by #3259; GitHub link-copy feedback timers covered by #3285; GitLab task filter repair covered by #3292. Continue large-file scan with separate GitHub, GitLab, Linear, and generic review behavior. | -| Onboarding / feature wall | `components/onboarding/**`, `components/feature-wall/**` | Inventory complete, manual review in progress | Select portal root Effects covered by #3087; tour visited-marker Effects covered by #3095; persisted completion close reset covered by #3188; onboarding settings hydration covered by #3198; unused orchestration notifications page deleted by #3273; feature-wall skill installed-state notification Effects covered by #3281; session-depth open publish Effects covered by #3283; shared reduced-motion media-query hook covered by #3287. Continue with demo timers and telemetry while avoiding telemetry semantic changes. | -| Status, dashboard, activity | `components/status-bar/**`, `components/dashboard/**`, `components/activity/**`, `components/pet/**` | Inventory complete, manual review in progress | Status-bar account menu close reset covered by #3051; pet overlay size clamp covered by #3118; Activity selected-thread repair covered by #3126; Workspace Space row-state repairs covered by #3168; Resource Manager Space scan-ready latch covered by #3200; pet reduced-motion subscription covered by #3287. Check interval sharing, retained agent state, activity terminal portals. | -| Mobile app routes | `mobile/app/**` | Inventory complete, manual review in progress | Pair-confirm route-code parsing covered by #3240; host home new-worktree route action covered by #3242; Accounts unused client ref covered by #3244; mobile home shared-client ref mirror covered by #3247; session route terminal/tab ref mirrors covered by #3250; tasks route client ref mirror covered by #3253; tasks route workspace agent selection covered by #3261. Continue with large `tasks.tsx` state/polling Effects, session route cleanup Effects, home route ref mirrors, and remote-client parity. | -| Mobile shared source | `mobile/src/**` | Inventory complete, manual review in progress | TextInputModal and CustomKeyModal open-state resets covered by #3223; BottomDrawer mount bookkeeping covered by #3226; MobileBrowserPane address sync covered by #3228; MobileBrowserPane ref mirrors covered by #3230; useMobileDictation ref mirrors covered by #3234; NewWorktreeModal agent selection covered by #3238. Continue with transport client context plus NewWorktreeModal open/reset, SSH, agent-detection, and setup-hook fetch Effects. | -| Expo two-way audio hook | `mobile/packages/expo-two-way-audio/src/hooks.ts` | Inventory complete, manual review pending | Single Effect plus `useSyncExternalStore`; verify native subscription cleanup. | -| Tests with hook mocks | `*.test.ts`, `*.test.tsx`, e2e comments | Inventory complete, manual review pending | Do not count comment-only mentions as app Effect sites; update tests beside behavior changes. | - -## Current Findings Queue - -These are candidate batches, not final conclusions. Each item needs code inspection before implementation. - -| Candidate PR | Area | Symptom / wasted work to prove | Likely files | Merge risk | -| ------------ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------- | -| PR A | Small controlled-input resets | Extra render pass from Effects that mirror one prop into local draft state. | `BrowserAddressBar.tsx` covered by #3038; `BrowserFind.tsx` covered by #3275; continue with `PdfFind.tsx`, selected settings draft fields | Low to medium | -| PR B | Settings draft hydration | Multiple independent Effects mirror persisted settings into draft state. | `components/settings/**` | Medium | -| PR C | Browser pane Effect cluster | Large Effect cluster may mix external webview sync, derived UI state, and event-specific state repair. | `BrowserPane.tsx`, `useGrabMode.ts`, browser tabs | High | -| PR D | Terminal tab repair and lifecycle | Effects repair active terminal/browser tab state after render; could cause extra render and focus churn. | `Terminal.tsx`, `TerminalPane.tsx` | High | -| PR E | Right-sidebar polling and source-control state repair | PR checks, branch snapshots, file explorer watch/reveal may repeat work after unrelated state changes. | `ChecksPanel.tsx`, `SourceControl.tsx`, `useGitStatusPolling.ts`, file explorer hooks | High | -| PR F | Mobile route monoliths | Mobile task/session routes have many chained Effects and timer/refetch patterns. | `mobile/app/h/[hostId]/tasks.tsx`, `mobile/app/h/[hostId]/session/[worktreeId].tsx` | High | -| PR G | Mobile shared components | Visible/open Effects reset modal/drawer state and browser address state. | `mobile/src/components/**`, `mobile/src/browser/MobileBrowserPane.tsx` | Medium | -| PR H | Editor search/preview/decorations | Debounce and scroll-restore Effects may be legitimate external sync but need cleanup/count review. | `components/editor/**` | Medium to high | -| PR I | Feature wall/onboarding demos | Animation/demo Effects can often move to event handlers or tighter custom hooks. | `components/feature-wall/**`, `components/onboarding/**` | Low to medium | -| PR J | GitHub filter controls | Extra render pass from mirroring parsed reviewer qualifier into local mode state. | `PRFilterDropdowns.tsx` covered by #3041 | Low | -| PR K | Sidebar project filter | Extra render pass from mirroring the first filtered repo into command selection state. | `SidebarFilter.tsx` covered by #3042 | Low | -| PR L | Diff note edit draft | Extra render pass from mirroring saved note body into edit draft while not editing. | `DiffCommentCard.tsx` covered by #3044 | Low | -| PR M | Status-bar account menus | Extra render pass from closing Claude/Codex account submenus in Effects after the provider menu closes. | `StatusBar.tsx` covered by #3051 | Low | -| PR N | Browser tab favicon fallback | Extra render pass from resetting failed favicon state in an Effect after tab favicon identity changes. | `BrowserTab.tsx` covered by #3052 | Low | -| PR O | Workspace title rename draft | Redundant draft sync Effect runs while the inline title rename input is not mounted. | `WorktreeTitleInlineRename.tsx` covered by #3053 | Low | -| PR P | Feature-wall tour static substep repair | Three no-op Effects scan static step arrays and repair ids that are only written from those arrays. | `FeatureWallTourSurface.tsx` covered by #3054 | Low | -| PR Q | Repo combobox mount-open state | Mount-only auto-open path uses an Effect and ref guard instead of initializing state from the prop. | `RepoCombobox.tsx` covered by #3055 | Low | -| PR R | Quick Open query reset | Extra render pass from clearing the Quick Open input after the dialog opens. | `QuickOpen.tsx` covered by #3056 | Low | -| PR S | Project group dialog open resets | Extra render pass from seeding name/delete dialog local state after the dialog opens. | `ProjectGroupNameDialog.tsx`, `ProjectGroupDeleteDialog.tsx` covered by #3058 | Low | -| PR T | Onboarding agent fallback disclosure | Extra render pass from opening the fallback agent list when a selected agent first appears there. | `AgentStep.tsx` covered by #3059 | Low | -| PR U | Feature-wall tour workflow resets | Four local reset Effects run after workflow changes or close instead of in the selection/close path. | `FeatureWallTourSurface.tsx` covered by #3060 | Low | -| PR V | Workspace board selection pruning | Local kanban selection is repaired in an Effect after the drawer closes or board rows change. | `use-workspace-kanban-selection.ts` covered by #3062 | Low | -| PR W | Workspace board column resize | Two mirror Effects sync the latest commit callback and external committed width after render. | `use-workspace-kanban-column-resize.ts` covered by #3063 | Low | -| PR X | Terminal quick-command dialog draft | Dialog draft and agent preset search are reset in an Effect after the dialog opens or retargets. | `TerminalQuickCommandDialog.tsx` covered by #3064 | Low | -| PR Y | Onboarding notification settings ref | A ref mirror Effect keeps notification handlers pointed at the latest settings. | `NotificationStep.tsx` covered by #3065 | Low | -| PR Z | Onboarding agent-selection ref mirrors | Five ref mirror Effects keep stable onboarding handlers pointed at latest selection/detection snapshots. | `use-onboarding-flow.ts` covered by #3066 | Low | -| PR AA | Workspace board drag ref mirrors | Area-selection and card-drag pointer handlers receive latest board callbacks through Effect-updated refs. | `use-workspace-kanban-area-selection.ts`, `use-workspace-kanban-card-pointer-drag.ts` covered by #3067 | Low | -| PR AB | Feature-wall tour telemetry refs | Close telemetry reads source/depth payload inputs through Effect-updated refs. | `use-feature-wall-tour-telemetry.ts` covered by #3068 | Low | -| PR AC | Onboarding persisted theme ref | The theme cleanup ref mirrors the latest persisted setting through an Effect. | `use-onboarding-flow.ts` covered by #3069 | Low | -| PR AD | Terminal window blur restart snapshot | A mount-only Effect rewrites the same startup blur snapshot already captured by the lazy ref initializer. | `TerminalWindowSection.tsx` covered by #3070 | Medium | -| PR AE | New-workspace composer note ref | A note ref mirror Effect runs after each note edit so stable PR/MR selection callbacks can read latest text. | `useComposerState.ts` covered by #3073 | Medium | -| PR AF | Source-control selection ref mirrors | Four ref mirror Effects keep stable source-control row handlers pointed at latest selection inputs. | `useSourceControlSelection.ts` covered by #3075 | High | -| PR AG | Notebook cell editor callback refs | A ref mirror Effect keeps embedded Monaco save/deactivate handlers pointed at latest callbacks. | `IpynbViewer.tsx` covered by #3077 | Medium | -| PR AH | Rich markdown editor ref mirrors | Nine ref mirror Effects keep stable ProseMirror/menu handlers pointed at latest editor UI state. | `RichMarkdownEditor.tsx` covered by #3079 | Medium | -| PR AI | Browser pane ref mirrors | Eight ref mirror Effects keep browser/webview callbacks and latest tab state available to stable handlers. | `BrowserPane.tsx`, `useGrabMode.ts` covered by #3081 | High | -| PR AJ | Untitled markdown rename draft reset | Save-as dialog draft fields reset in an Effect after open instead of before the first open paint. | `UntitledFileRenameDialog.tsx` covered by #3083 | Low | -| PR AK | Onboarding/feature-wall select portal roots | Two mount Effects query the active overlay/dialog root only to keep select menus above fullscreen surfaces. | `NotificationStep.tsx`, `AiCommitPrSettingsCard.tsx` covered by #3087 | Low | -| PR AL | Orca hook trust dialog checkbox reset | The "always trust" checkbox resets after open, leaving one possible paint with the previous decision. | `OrcaYamlTrustDialog.tsx` covered by #3089 | Medium | -| PR AM | Confirmation dialog active request mirror | Active confirmation request was mirrored into local state/ref in an Effect, adding a render pass per request. | `confirmation-dialog.tsx` covered by #3091 | Low | -| PR AN | Repository hook local editor visibility | The local hook script editor was revealed by an Effect after persisted local script content arrived. | `RepositoryHooksSection.tsx` covered by #3093 | Medium | -| PR AO | Feature-wall tour visited markers | Four Effects marked visible workflows/substeps as visited after render instead of in the open/select/continue transitions. | `FeatureWallTourSurface.tsx` covered by #3095 | Medium | -| PR AP | Editor copy feedback timers | Copy success indicators started their auto-clear timers in Effects after render rather than in the copy handlers. | `EditorPanel.tsx`, `MarkdownPreview.tsx` covered by #3097 | Medium | -| PR AQ | Combobox open focus | Four comboboxes focused search inputs and seeded cmdk highlight state in Effects after opening. | `AgentCombobox.tsx`, `RepoCombobox.tsx`, `WorkspaceCombobox.tsx`, `CreateFromPicker.tsx` covered by #3100 | Low | -| PR AR | Automation run-table selection repair | External automation run selection was repaired in an Effect after the visible run page changed. | `ExternalAutomationRunTable.tsx` covered by #3102 | Medium | -| PR AS | Automation run-history selection repair | Run-history row selection was repaired in an Effect after the automation run list changed. | `AutomationRunHistory.tsx` covered by #3104 | Low | -| PR AT | Automation detail-page selection resets | Internal and external automation run detail pages were cleared in Effects after selected automation/external keys changed. | `AutomationsPage.tsx` covered by #3106 | Medium | -| PR AU | Linear estimate input sync | Linear custom estimate input was mirrored from the issue estimate in an Effect while the popover was closed. | `LinearItemDrawer.tsx` covered by #3108 | Medium | -| PR AV | Linear sub-issue list sync | Linear sub-issue rows were mirrored from the selected issue in an Effect so optimistic creates could append local rows. | `LinearIssueWorkspace.tsx` covered by #3110 | Medium | -| PR AW | GitHub project date draft sync | GitHub Project date cells mirrored the source field value into local input draft state in an Effect. | `ProjectCell.tsx` covered by #3112 | Medium | -| PR AX | GitHub reviewer picker active index | Three GitHub review request pickers reset active keyboard selection in Effects after query or row-count changes. | `PullRequestPage.tsx`, `GitHubItemDialog.tsx`, `TaskPage.tsx` covered by #3114 | Medium | -| PR AY | GitHub Project column state | Project view column visibility and width preferences were reloaded in Effects after project/view scope changes. | `ProjectViewList.tsx` covered by #3116 | Medium | -| PR AZ | Pet overlay size clamp | Pet overlay position was repaired in an Effect after the persisted overlay size changed. | `PetOverlay.tsx` covered by #3118 | Medium | -| PR BA | Combined diff local state repairs | File-tree highlight and clear-notes dialog state were repaired in Effects after diff entry or note-count changes. | `CombinedDiffViewer.tsx` covered by #3120 | Medium | -| PR BB | Onboarding settings detour reset | The App-root onboarding detour flag was cleared in an Effect after Settings navigation or onboarding eligibility changed. | `App.tsx` covered by #3122 | Low | -| PR BC | GitLab comment draft reset | The GitLab MR/issue comment draft was cleared in an Effect after the selected sheet item changed. | `GitLabItemDialog.tsx` covered by #3124 | Medium | -| PR BD | Activity selected-thread repair | Activity selected thread state was cleared in an Effect after retained agent/tab rows changed. | `ActivityPrototypePage.tsx` covered by #3126 | Medium | -| PR BE | Settings lazy-section mounted state | Settings remembered newly needed lazy sections in an Effect after search/navigation changed the visible set. | `Settings.tsx` covered by #3128 | Medium | -| PR BF | Linear title/description draft sync | Linear issue title and description drafts were synced from source issue data in an Effect after item or field updates. | `LinearIssueTextEditor.tsx` covered by #3130 | Medium | -| PR BG | Automations create-from branch search | Create-from branch search query/results were cleared in an Effect after the selected repo changed. | `CreateFromPicker.tsx` covered by #3132 | Medium | -| PR BH | Delete dialog one-shot confirmation | The delete-workspace "don't ask again" checkbox was reset in an Effect after the dialog closed. | `DeleteWorktreeDialog.tsx` covered by #3134 | Medium | -| PR BI | Browser settings text drafts | Browser home-page and Kagi session-link drafts were synced from persisted settings in Effects after source changes. | `BrowserPane.tsx`, `KagiSessionLinkForm.tsx` covered by #3136 | Medium | -| PR BJ | General settings drafts | Auto-save delay and Open In launcher drafts were synced from persisted settings in Effects after source changes. | `GeneralPane.tsx` covered by #3138 | Medium | -| PR BK | Source Control AI instruction drafts | Commit-message, pull-request, and branch-name instruction drafts plus parent discard reset were synchronized in Effects. | `CommitMessageAiPane.tsx` covered by #3140 | Medium | -| PR BL | Notification volume draft | Notification custom-sound volume draft and latest-settings ref were synchronized in an Effect after settings changes. | `NotificationsPane.tsx` covered by #3142 | Medium | -| PR BM | Repo Source Control AI override draft | Repo-specific Source Control AI override draft was synchronized from persisted repo settings in an Effect after source changes. | `RepositorySourceControlAiSection.tsx` covered by #3144 | Medium | -| PR BN | Quick Commands add intent | Quick Commands add-dialog deep-link intent opened from a settings signal in an Effect after the pane rendered. | `QuickCommandsPane.tsx` covered by #3146 | Medium | -| PR BO | Quick workspace agent selection | Quick-create agent override was repaired in an Effect after detection or disabled-agent settings invalidated it. | `NewWorkspaceComposerModal.tsx`, `quick-workspace-agent-selection.ts` covered by #3148 | Medium | -| PR BP | Add Repo clone destination default | Clone destination was auto-filled in an Effect after entering the local clone step or after settings hydration. | `AddRepoDialog.tsx`, `clone-defaults.ts` covered by #3150 | Medium | -| PR BQ | Mobile QR pairing poll count | The paired-device count used to start QR polling was captured in an Effect after the QR data URL rendered. | `MobilePane.tsx` covered by #3152 | Medium | -| PR BR | Integrations preflight status mirrors | Source-host integration statuses and account labels were mirrored from preflight store state in an Effect after Settings rendered. | `IntegrationsPane.tsx`, `integrations-pane-status.ts` covered by #3154 | Medium | -| PR BS | SSH reset-relay dialog cleanup | Pending reset-relay confirmation was cleared in an Effect after the selected target began connecting or reconnecting. | `SshTargetDestructiveActions.tsx`, `ssh-target-action-state.ts` covered by #3157 | High | -| PR BT | Sidebar live-width mirror | Sidebar root mirrored the committed width into the live drag CSS variable in a second passive Effect. | `components/sidebar/index.tsx` covered by #3160 | Low | -| PR BU | Cmd+J selected-row repair | The jump palette repaired selected row state in an Effect after query or result-list changes. | `WorktreeJumpPalette.tsx`, `worktree-palette-create-action.test.ts` covered by #3164 | Low | -| PR BV | Sidebar selected-worktree pruning | The sidebar pruned selected worktree ids in an Effect after filters or grouping hid selected rows. | `WorktreeList.tsx` covered by #3166 | Low | -| PR BW | Workspace Space row-state repairs | The space manager repaired inspected row, selected ids, and treemap zoom in Effects after scan rows changed. | `WorkspaceSpaceManagerPanel.tsx`, `workspace-space-presentation.ts` covered by #3168 | Medium | -| PR BX | Mobile driver overlay collapse reset | The terminal mobile-driver overlay re-expanded from collapsed chip mode in an Effect after driver changes. | `MobileDriverOverlay.tsx`, `mobile-driver-overlay-collapse.ts` covered by #3170 | Medium | -| PR BY | Diagnostics bundle cleanup ref | The retained diagnostics bundle id was mirrored into a cleanup ref by a passive Effect after bundle creation. | `PrivacyDiagnosticsSection.tsx` covered by #3176 | Medium | -| PR BZ | Mobile pairing page stage repairs | Mobile page polling flipped from QR flow to paired summary, then reset the paired-view polling baseline, in two follow-up Effects. | `MobilePage.tsx`, `mobile-page-stage.ts` covered by #3180 | Medium | -| PR CA | Create PR submit close reset | The hosted review creation dialog reset submit/error state in an Effect after closing. | `CreatePullRequestDialog.tsx`, `ChecksPanel.tsx` covered by #3184 | Medium | -| PR CB | GitHub Project visible-table cache | The last filtered GitHub Project table was cached in state by an Effect after slug-index filtering completed. | `ProjectViewWrapper.tsx`, `project-visible-table-cache.ts` covered by #3186 | Medium | -| PR CC | Feature-wall persisted completion close reset | Persisted feature-wall completion sets were reread from localStorage in an Effect after the modal closed. | `use-persisted-feature-wall-completion.ts` covered by #3188 | Medium | -| PR CD | Smart workspace command highlight | The smart new-workspace picker repaired cmdk command highlight in an Effect after rows, query freshness, or source intent changed. | `SmartWorkspaceNameField.tsx`, `smart-workspace-command-value.ts` covered by #3192 | Medium | -| PR CE | Workspace cleanup active view | The cleanup dialog repaired the active nav section in an Effect after scan/filter counts made the requested section empty. | `WorkspaceCleanupDialog.tsx`, `workspace-cleanup-view-selection.ts` covered by #3195 | Medium | -| PR CF | Onboarding settings hydration | The onboarding wizard re-synced saved theme/default-agent settings in an Effect after async settings hydration. | `use-onboarding-flow.ts`, `onboarding-settings-hydration.ts` covered by #3198 | Medium | -| PR CG | Resource Manager Space scan-ready latch | The status-bar Resource Manager ready dot was repaired in an Effect after Space scan completion, popover open, or Space page navigation. | `ResourceUsageStatusSegment.tsx`, `resource-usage-space-scan-ready.ts` covered by #3200 | Medium | -| PR CH | Commit failure dialog identity | The Source Control commit failure details dialog reset stale open state in an Effect after the active failure identity changed. | `SourceControl.tsx`, `commit-failure-dialog-state.ts` covered by #3202 | Medium | -| PR CI | Diff comments clear confirmation | The Source Control note-clear confirmation closed stale pending state in an Effect after active worktree or note counts changed. | `SourceControl.tsx`, `diff-comments-clear-dialog-state.ts` covered by #3206 | Medium | -| PR CJ | Diff notes copy feedback timers | The Source Control note copy success indicators started auto-dismiss timers in Effects after copy handlers set copied state. | `SourceControl.tsx` covered by #3210 | Medium | -| PR CK | Source Control selected-key pruning | Bulk selection keys and range anchors were pruned in an Effect after visible Source Control rows changed. | `useSourceControlSelection.ts` covered by #3212 | High | -| PR CL | GitHub Project dialog state | Project repo-backed and slug fallback dialogs were closed in Effects after repo membership or slug-index readiness changed. | `ProjectViewWrapper.tsx`, `project-dialog-state.ts` covered by #3214 | Medium | -| PR CM | Combined diff note-copy timer | The combined diff note-copy success indicator started its auto-dismiss timer in an Effect after the copy handler set copied state. | `CombinedDiffViewer.tsx` covered by #3218 | Medium | -| PR CN | App lazy-modal mount state | Root lazy modal ids were retained in an Effect after `activeModal` changed, adding a render pass before first-use modal mount. | `App.tsx`, `lazy-modal-mount-state.ts` covered by #3220 | Medium | -| PR CO | Mobile shared modal open resets | Mobile TextInput and custom-key modals reset local drafts in Effects after opening, leaving one possible stale first paint. | `TextInputModal.tsx`, `CustomKeyModal.tsx` covered by #3223 | Low | -| PR CP | Mobile BottomDrawer mounted state | The shared mobile drawer mounted after opening in a passive Effect, adding a null commit before drawer animations could start. | `BottomDrawer.tsx`, `bottom-drawer-mount-state.ts` covered by #3226 | Medium | -| PR CQ | Mobile browser address sync | The mobile browser address field synced tab URL changes in an Effect after focus/tab updates, leaving one stale paint after blur or tab URL changes. | `MobileBrowserPane.tsx`, `mobile-browser-address-sync.ts` covered by #3228 | Medium | -| PR CR | Mobile browser ref mirrors | Mobile browser gesture and stream handlers received frame metadata, layout, dialog, and zoom state through passive ref-mirror Effects. | `MobileBrowserPane.tsx` covered by #3230 | Medium | -| PR CS | Mobile dictation ref mirrors | Native audio/RPC dictation handlers received client, enabled, transcript, and error callbacks through a passive ref-mirror Effect. | `use-mobile-dictation.ts` covered by #3234 | Medium | -| PR CT | Mobile new-worktree agent selection | The create-workspace modal repaired preferred and unavailable selected-agent state in passive Effects after settings or agent detection changed. | `NewWorktreeModal.tsx`, `new-worktree-agent-selection.ts` covered by #3238 | Medium | -| PR CU | Mobile pair-confirm route parsing | The deep-link pair-confirm screen rendered once without an offer/error, then parsed the route pairing code and repaired status in a passive Effect. | `pair-confirm.tsx`, `pair-confirm-state.ts` covered by #3240 | Medium | -| PR CV | Mobile host new-worktree route action | Host home opened the create-workspace modal from `action=newWorktree` in a passive Effect after the route rendered. | host route index, `host-route-action-state.ts` covered by #3242 | Medium | -| PR CW | Mobile Accounts unused client ref | The Accounts route mirrored `client` into a ref in a passive Effect, but no code read that ref. | `mobile/app/h/[hostId]/accounts.tsx` covered by #3244 | Low | -| PR CX | Mobile home shared-client ref mirror | The home route copied shared host clients into a focus-callback ref in a passive Effect, delaying focus refreshes by one commit. | `mobile/app/index.tsx` covered by #3247 | Medium | -| PR CY | Mobile session terminal/tab ref mirrors | The mobile session route copied active tab, tab list, markdown doc, client, and connection snapshots into imperative refs in passive Effects after terminal input/session callbacks could already run. | `mobile/app/h/[hostId]/session/[worktreeId].tsx` covered by #3250 | High | -| PR CZ | Mobile tasks client ref mirror | The mobile Tasks route copied the shared client into a stale-request guard ref in a passive Effect, delaying guard freshness for async task loads. | `mobile/app/h/[hostId]/tasks.tsx` covered by #3253 | High | -| PR DA | GitHub assignee dirty-flag reset | GitHub issue/PR edit sections reset the optimistic assignee-edit guard in a passive Effect after an item switch, so the assignee sync Effect could skip the new item for one commit. | `GitHubItemDialog.tsx`, `PullRequestPage.tsx` covered by #3255 | Medium | -| PR DB | DiffViewer first-diff auto-scroll reset | DiffViewer reset its one-shot first-diff scroll flag in a layout Effect after `modelKey` changed. | `DiffViewer.tsx` covered by #3257 | Medium | -| PR DC | GitHub reviewer-request local sync | GitHub reviewer request pickers mirrored incoming `reviewRequests` into optimistic local state in passive Effects after item switches/refetches. | `GitHubItemDialog.tsx`, `PullRequestPage.tsx`, `TaskPage.tsx` covered by #3259 | Medium | -| PR DD | Mobile Tasks workspace agent selection | The workspace-create drawer repaired automatic and unavailable agent choices in passive Effects after settings or SSH/local detection changed. | `mobile/app/h/[hostId]/tasks.tsx`, `workspace-agent-selection.ts` covered by #3261 | Medium | -| PR DE | Sidebar SSH reconnect prompts | Active sidebar worktree and nested lineage-child reconnect prompts opened from passive Effects after disconnected SSH state rendered. | `WorktreeCard.tsx`, `WorktreeList.tsx` covered by #3267 | High | -| PR DF | Floating workspace first-open bounds | Floating workspace panel normalized first-open bounds in a passive Effect after the initial hidden/open commit. | `FloatingTerminalPanel.tsx` covered by #3269 | Medium | -| PR DG | Unused terminal shell hooks | Four React Effect call sites lived only in unused terminal shell/shortcut/hook files. | `TerminalShell.tsx`, `useTerminalShortcuts.ts`, `useTerminalTabs.ts` covered by #3271 | Low | -| PR DH | Unused feature-wall notifications page | Two React Effect call sites lived only in an unused feature-wall orchestration page. | `NotificationsPage.tsx` covered by #3273 | Low | -| PR DI | Browser find debounce state | The webview find bar stored a debounced copy of query state through a second passive Effect before searching. | `BrowserFind.tsx` covered by #3275 | Medium | -| PR DJ | Editor path-copy feedback timer | The editor header path-copy toast started its auto-dismiss timer in a passive Effect after the copy handler set copied state. | `EditorPanel.tsx` covered by #3277 | Medium | -| PR DK | Feature-wall skill installed state | Orchestration and browser-use setup cards notified the tour parent of installed state from passive Effects after render. | `FeatureWallTourSurface.tsx`, `FeatureWallBody.tsx`, `FeatureWallTourPanel.tsx`, `OrchestrationSetupCard.tsx`, `BrowserUseSkillSetupCard.tsx`, `useInstalledAgentSkills.ts` covered by #3281 | Medium | -| PR DL | Feature-wall session-depth publishing | Session-depth reset and summary-publish Effects both published the same tour-depth summary on the modal open edge. | `use-feature-wall-session-depth.ts` covered by #3283 | Medium | -| PR DM | GitHub link-copy feedback timers | GitHub issue/PR link-copy buttons started their auto-dismiss timers from copied-state Effects after the copy handler set local feedback state. | `GitHubItemDialog.tsx`, `PullRequestPage.tsx` covered by #3285 | Medium | -| PR DN | Shared reduced-motion media query hook | UpdateCard, PetOverlay, and feature-wall/onboarding helpers each owned separate media-query subscription Effects for the same reduced-motion preference. | `usePrefersReducedMotion.ts`, `UpdateCard.tsx`, `PetOverlay.tsx`, `feature-wall-modal-helpers.ts` covered by #3287 | Medium | -| PR DO | PDF find open/reset state | PDF find focused the input and closed/reset match counters from a passive open-state Effect after the find bar rendered or hid. | `PdfFind.tsx`, `PdfViewer.tsx` covered by #3289 | Medium | -| PR DP | GitLab task filter repair | Switching between GitLab MR and issue views repaired invalid stale chip filters in a passive Effect after render. | `TaskPage.tsx` covered by #3292 | Medium | -| PR DQ | Nested repo select-all indeterminate state | The nested repo select-all checkbox set its visual indeterminate state in a passive Effect after selection changes. | `NestedRepoTreePreview.tsx` covered by #3294 | Low | -| PR DR | Workspace title rename focus | Inline workspace title rename focused and selected its input from a passive Effect after edit mode rendered. | `WorktreeTitleInlineRename.tsx` covered by #3296 | Low | -| PR DS | Workspace title editing notification | Inline workspace title rename notified parent cards that editing mode changed from a passive Effect after the rename input rendered. | `WorktreeTitleInlineRename.tsx` covered by #3298 | Low | -| PR DT | Worktree metadata notes resize | The worktree metadata notes textarea resized itself from a passive Effect after the comment draft rendered. | `WorktreeMetaDialog.tsx` covered by #3300 | Low | -| PR DU | Editor tab rename focus | Editor file-tab rename focused and selected the filename input from a passive Effect after rename mode rendered. | `EditorFileTab.tsx` covered by #3303 | Medium | -| PR DV | Tab entry selected action reset | The new tab-entry menu reset its highlighted action in a passive Effect after the query changed. | `TabBarCreateEntry.tsx` covered by #3311 | Medium | -| PR DW | Terminal tab rename focus | Terminal tab inline rename focused and selected its input from a passive Effect after edit mode rendered. | `SortableTab.tsx` covered by #3314 | Medium | -| PR DX | Rich markdown link bubble focus | The link-edit bubble focused and selected the URL input from a passive Effect after edit mode mounted. | `RichMarkdownLinkBubble.tsx` covered by #3316 | Medium | -| PR DY | Image viewer preview source | Image previews built a blob URL and reset stale load-error state from a passive Effect after the editor rendered. | `ImageViewer.tsx` covered by #3318 | Medium | -| PR DZ | Markdown preview search focus | Markdown preview search focused and selected the find input from a passive Effect after search mode rendered. | `MarkdownPreview.tsx` covered by #3322 | Medium | - -## Merge Risk Scale - -| Risk | Criteria | Required verification | -| ------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| Low | Isolated local UI state, no persistence, no IPC, no terminal/browser/source-control behavior. | Targeted unit test if available plus `pnpm run typecheck:web`. | -| Medium | Settings, editor UI, mobile local UI, or state persisted per workspace but no transport protocol changes. | Targeted tests plus manual interaction or focused Playwright/Electron check when UI behavior changes. | -| High | Terminal/PTY, browser webview, source control, mobile remote-client, SSH, polling, persistence, or provider behavior. | Targeted tests, `pnpm run typecheck:web`, and Electron or mobile/manual parity evidence as appropriate. | - -## PR Log - -| PR | Branch | Area | Risk | Status | Evidence | -| ----- | ------------------------------------ | ----------------------------------------------------------------------------- | ---- | ------ | ------------------------------------------------------------------------------------------------------------ | -| #3038 | `nwparker/react-perf` | Audit ledger plus browser address bar top-suggestion mirror Effect removal | Low | Merged | `pnpm exec oxlint src/renderer/src/components/browser-pane/BrowserAddressBar.tsx`; `pnpm run typecheck:web`. | -| #3041 | `nwparker/react-perf-pr-filter` | PR reviewer filter mode derived from parsed query plus explicit user override | Low | Merged | `pnpm exec oxlint src/renderer/src/components/github/PRFilterDropdowns.tsx`; `pnpm run typecheck:web`. | -| #3042 | `nwparker/react-perf-sidebar-filter` | Sidebar project filter command selection derived from filtered repos | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/SidebarFilter.tsx`; `pnpm run typecheck:web`. | -| #3044 | `nwparker/react-perf-low-risk-2` | Diff note card removes saved-body-to-draft mirror Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/diff-comments/DiffCommentCard.tsx`; `pnpm run typecheck:web`. | -| #3051 | `nwparker/react-perf-low-risk-3` | Status-bar account submenus collapse in provider menu open-change handlers | Low | Merged | `pnpm exec oxlint src/renderer/src/components/status-bar/StatusBar.tsx`; `pnpm run typecheck:web`. | -| #3052 | `nwparker/react-perf-low-risk-4` | Browser tab favicon failure reset happens during render for new favicon IDs | Low | Merged | `pnpm exec oxlint src/renderer/src/components/tab-bar/BrowserTab.tsx`; `pnpm run typecheck:web`. | -| #3053 | `nwparker/react-perf-low-risk-5` | Inline workspace-title rename removes inactive draft sync Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm run typecheck:web`. | -| #3054 | `nwparker/react-perf-low-risk-6` | Feature-wall tour removes static substep id repair Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm run typecheck:web`. | -| #3055 | `nwparker/react-perf-low-risk-7` | Repo combobox initializes mount-open state without an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/repo/RepoCombobox.tsx`; `pnpm run typecheck:web`. | -| #3056 | `nwparker/react-perf-low-risk-8` | Quick Open clears its query on the open edge without a reset Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/QuickOpen.tsx`; `pnpm run typecheck:web`. | -| #3058 | `nwparker/react-perf-project-group-dialogs` | Project group dialogs reset local open-state during render | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx src/renderer/src/components/sidebar/ProjectGroupNameDialog.tsx`; `pnpm run typecheck:web`. | -| #3059 | `nwparker/react-perf-agent-step-latch` | Onboarding agent fallback disclosure latch updates during render | Low | Merged | `pnpm exec oxlint src/renderer/src/components/onboarding/AgentStep.tsx`; `pnpm run typecheck:web`. | -| #3060 | `nwparker/react-perf-feature-tour-resets` | Feature-wall tour reset state moves into workflow/close transitions | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm run typecheck:web`. | -| #3062 | `nwparker/react-perf-kanban-selection` | Workspace board selection pruning moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts`; `pnpm run typecheck:web`. | -| #3063 | `nwparker/react-perf-kanban-column` | Workspace board column width mirror Effects move to render-time synchronization | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts`; `pnpm run typecheck:web`. | -| #3064 | `nwparker/react-perf-quick-command-dialog` | Terminal quick-command dialog resets draft state during render | Low | Merged | `pnpm exec oxlint src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx`; `pnpm run typecheck:web`. | -| #3065 | `nwparker/react-perf-notification-step-ref` | Onboarding notification settings ref mirror moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/onboarding/NotificationStep.tsx`; `pnpm run typecheck:web`. | -| #3066 | `nwparker/react-perf-onboarding-agent-refs` | Onboarding agent-selection ref mirrors move out of Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/onboarding/use-onboarding-flow.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/onboarding/agent-picked-payload.test.ts`; `pnpm run typecheck:web`. | -| #3067 | `nwparker/react-perf-kanban-drag-refs` | Workspace board drag ref mirrors move out of Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts`; `pnpm run typecheck:web`. | -| #3068 | `nwparker/react-perf-feature-wall-telemetry-ref` | Feature-wall tour telemetry ref mirror moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts`; `pnpm run typecheck:web`. | -| #3069 | `nwparker/react-perf-onboarding-theme-ref` | Onboarding persisted theme ref mirror moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/onboarding/use-onboarding-flow.ts`; `pnpm run typecheck:web`. | -| #3070 | `nwparker/react-perf-terminal-window-ref` | Terminal window blur startup snapshot removes redundant mount Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/TerminalWindowSection.tsx`; `pnpm run typecheck:web`. | -| #3073 | `nwparker/react-perf-composer-note-ref` | New-workspace composer note ref mirror moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/hooks/useComposerState.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/composer-branch-selection.test.ts`; `pnpm run typecheck:web`. | -| #3075 | `nwparker/react-perf-source-control-selection-refs` | Source-control selection ref mirrors move out of Effects | High | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/useSourceControlSelection.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts`; `pnpm run typecheck:web`. | -| #3077 | `nwparker/react-perf-ipynb-cell-editor-refs` | Notebook cell editor callback refs move out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/IpynbViewer.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/ipynb-parse.test.ts`; `pnpm run typecheck:web`. | -| #3079 | `nwparker/react-perf-rich-markdown-refs` | Rich markdown editor ref mirrors move out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownEditor.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx src/renderer/src/components/editor/markdown-doc-completions.test.ts src/renderer/src/components/editor/rich-markdown-commands.test.ts`; `pnpm run typecheck:web`. | -| #3081 | `nwparker/react-perf-browser-ref-mirrors` | Browser pane ref mirrors move out of Effects | High | Open | `pnpm exec oxlint src/renderer/src/components/browser-pane/useGrabMode.ts src/renderer/src/components/browser-pane/BrowserPane.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/browser-pane-page-selection.test.ts src/renderer/src/components/browser-pane/context-menu-positioning.test.ts`; `pnpm run typecheck:web`; Electron sanity launch/attach on CDP port 9333, branch identity confirmed, zero console errors, existing browser webview target observed. | -| #3083 | `nwparker/react-perf-untitled-rename-dialog` | Untitled markdown rename dialog draft reset moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/editor/UntitledFileRenameDialog.tsx`; `pnpm run typecheck:web`. | -| #3087 | `nwparker/react-perf-tour-select-portals` | Onboarding/feature-wall select portal roots move out of Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/AiCommitPrSettingsCard.tsx src/renderer/src/components/onboarding/NotificationStep.tsx`; `pnpm run typecheck:web`. | -| #3089 | `nwparker/react-perf-orca-yaml-trust-reset` | Orca hook trust checkbox reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx`; `pnpm run typecheck:web`. | -| #3091 | `nwparker/react-perf-confirmation-dialog` | Confirmation dialog active request mirror moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/confirmation-dialog.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 933 -> 932. | -| #3093 | `nwparker/react-perf-repo-hooks-local-editor` | Repository hook local editor visibility is derived from user-open state and non-empty local script content | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/RepositoryHooksSection.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 932 -> 931. | -| #3095 | `nwparker/react-perf-feature-wall-visited-events` | Feature-wall tour visited markers move from Effects to open/select/continue handlers | Medium | Open | `pnpm exec oxlint src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 932 -> 928. | -| #3097 | `nwparker/react-perf-editor-copy-timers` | Editor copy feedback clear timers move into the copy handlers | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx src/renderer/src/components/editor/EditorPanel.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 932 -> 930. | -| #3100 | `nwparker/react-perf-combobox-open-focus` | Combobox open focus and cmdk highlight seeding move out of Effects | Low | Merged | `pnpm exec oxlint src/renderer/src/components/agent/AgentCombobox.tsx src/renderer/src/components/repo/RepoCombobox.tsx src/renderer/src/components/automations/WorkspaceCombobox.tsx src/renderer/src/components/automations/CreateFromPicker.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/agent/AgentCombobox.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 932 -> 928. | -| #3102 | `nwparker/react-perf-automation-run-selection` | External automation run-table selection repair moves into page navigation/render derivation | Medium | Open | `pnpm exec oxlint src/renderer/src/components/automations/ExternalAutomationRunTable.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 928 -> 927. | -| #3104 | `nwparker/react-perf-automation-history-selection` | Automation run-history row selection is derived during render | Low | Merged | `pnpm exec oxlint src/renderer/src/components/automations/AutomationRunHistory.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 928 -> 927. | -| #3106 | `nwparker/react-perf-automation-page-resets` | Automation detail-page resets move into automation/external selection handlers | Medium | Open | `pnpm exec oxlint src/renderer/src/components/automations/AutomationsPage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 925. | -| #3108 | `nwparker/react-perf-linear-estimate-input` | Linear estimate input sync moves to popover open | Medium | Open | `pnpm exec oxlint src/renderer/src/components/LinearItemDrawer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3110 | `nwparker/react-perf-linear-subissues` | Linear sub-issue rows derive from issue data plus optimistic additions | Medium | Open | `pnpm exec oxlint src/renderer/src/components/LinearIssueWorkspace.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3112 | `nwparker/react-perf-project-date-draft` | GitHub project date-cell draft sync moves to render-time source reconciliation | Medium | Open | `pnpm exec oxlint src/renderer/src/components/github-project/ProjectCell.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3114 | `nwparker/react-perf-reviewer-index` | GitHub reviewer picker active index resets during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/PullRequestPage.tsx src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/TaskPage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 924. | -| #3116 | `nwparker/react-perf-project-view-columns` | GitHub Project column hidden/width state reloads during render on scope changes | Medium | Open | `pnpm exec oxlint src/renderer/src/components/github-project/ProjectViewList.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 925. | -| #3118 | `nwparker/react-perf-pet-size-clamp` | Pet overlay position clamps during render on size changes | Medium | Open | `pnpm exec oxlint src/renderer/src/components/pet/PetOverlay.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3120 | `nwparker/react-perf-combined-diff-state` | Combined diff file-tree highlight and clear-notes dialog repair during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/CombinedDiffViewer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 925. | -| #3122 | `nwparker/react-perf-onboarding-detour` | App onboarding settings detour reset moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/App.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 927 -> 926. | -| #3124 | `nwparker/react-perf-gitlab-comment-draft` | GitLab comment draft reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/GitLabItemDialog.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3126 | `nwparker/react-perf-activity-selection` | Activity selected-thread repair moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/activity/ActivityPrototypePage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3128 | `nwparker/react-perf-settings-mounted-sections` | Settings lazy-section mounted bookkeeping moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/Settings.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3130 | `nwparker/react-perf-linear-text-drafts` | Linear title/description draft sync moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/LinearIssueTextEditor.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3132 | `nwparker/react-perf-create-from-search` | Automations create-from branch search reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/automations/CreateFromPicker.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3134 | `nwparker/react-perf-delete-worktree-confirm` | Delete-workspace one-shot confirmation checkbox reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3136 | `nwparker/react-perf-browser-settings-drafts` | Browser home-page and Kagi session-link draft sync moves out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/BrowserPane.tsx src/renderer/src/components/settings/KagiSessionLinkForm.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 924. | -| #3138 | `nwparker/react-perf-general-settings-drafts` | General auto-save delay and Open In launcher draft sync moves out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/GeneralPane.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/GeneralPane.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 924. | -| #3140 | `nwparker/react-perf-commit-ai-drafts` | Source Control AI instruction draft sync and discard reset move out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/CommitMessageAiPane.tsx src/renderer/src/components/settings/CommitMessageAiPane.test.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/CommitMessageAiPane.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 921. | -| #3142 | `nwparker/react-perf-notification-volume-draft` | Notification volume draft sync moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/NotificationsPane.tsx src/renderer/src/components/settings/NotificationsPane.test.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/NotificationsPane.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3144 | `nwparker/react-perf-repo-source-control-ai-draft` | Repo Source Control AI override draft sync moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3146 | `nwparker/react-perf-quick-commands-intent` | Quick Commands add-intent signal consumption moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/QuickCommandsPane.tsx src/renderer/src/components/settings/QuickCommandsPane.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/QuickCommandsPane.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3148 | `nwparker/react-perf-quick-agent-selection` | Quick workspace agent override repair moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/NewWorkspaceComposerModal.tsx src/renderer/src/lib/quick-workspace-agent-selection.ts src/renderer/src/lib/quick-workspace-agent-selection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/quick-workspace-agent-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3150 | `nwparker/react-perf-add-repo-clone-default` | Add Repo clone destination auto-fill moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/sidebar/AddRepoDialog.tsx src/renderer/src/components/sidebar/clone-defaults.ts src/renderer/src/components/sidebar/clone-defaults.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/clone-defaults.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3152 | `nwparker/react-perf-mobile-qr-polling` | Mobile QR device-count capture moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/MobilePane.tsx src/renderer/src/components/settings/mobile-pairing-device-polling.ts src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3154 | `nwparker/react-perf-integrations-status` | Integrations preflight status mirrors move out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/IntegrationsPane.tsx src/renderer/src/components/settings/integrations-pane-status.ts src/renderer/src/components/settings/integrations-pane-status.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/integrations-pane-status.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3157 | `nwparker/react-perf-ssh-reset-dialog` | SSH reset-relay dialog cleanup moves out of an Effect | High | Open | `pnpm exec oxlint src/renderer/src/components/settings/SshTargetDestructiveActions.tsx src/renderer/src/components/settings/ssh-target-action-state.ts src/renderer/src/components/settings/ssh-target-action-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/ssh-target-action-state.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3160 | `nwparker/react-perf-sidebar-live-width` | Redundant sidebar live-width mirror Effect removed | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/index.tsx src/renderer/src/hooks/useSidebarResize.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useSidebarResize.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 926 -> 925. | -| #3164 | `nwparker/react-perf-worktree-palette-selection` | Cmd+J selected-row repair moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/WorktreeJumpPalette.tsx src/renderer/src/lib/worktree-palette-create-action.ts src/renderer/src/lib/worktree-palette-create-action.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/worktree-palette-create-action.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 925 -> 924. | -| #3166 | `nwparker/react-perf-sidebar-selection-prune` | Sidebar selected-worktree pruning moves out of an Effect | Low | Merged | `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeList.tsx src/renderer/src/components/sidebar/worktree-multi-selection.ts src/renderer/src/components/sidebar/worktree-multi-selection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/worktree-multi-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 924 -> 923. | -| #3168 | `nwparker/react-perf-workspace-space-state` | Workspace Space row-state repairs move out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx src/renderer/src/components/status-bar/workspace-space-presentation.ts src/renderer/src/components/status-bar/workspace-space-presentation.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/workspace-space-presentation.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 920. | -| #3170 | `nwparker/react-perf-mobile-driver-overlay-collapse` | Mobile driver overlay collapse reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.ts src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3176 | `nwparker/react-perf-diagnostics-bundle-ref` | Diagnostics bundle cleanup ref mirror moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/PrivacyPane.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3180 | `nwparker/react-perf-mobile-page-stage` | Mobile pairing page stage repairs move into the device refresh path | Medium | Open | `pnpm exec oxlint src/renderer/src/components/mobile/MobilePage.tsx src/renderer/src/components/mobile/mobile-page-stage.ts src/renderer/src/components/mobile/mobile-page-stage.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/mobile/mobile-page-stage.test.ts src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 921. | -| #3184 | `nwparker/react-perf-create-pr-submit-reset` | Create PR dialog submit reset moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx src/renderer/src/components/right-sidebar/ChecksPanel.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3186 | `nwparker/react-perf-project-visible-table-cache` | GitHub Project visible-table cache moves out of an Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/github-project/ProjectViewWrapper.tsx src/renderer/src/components/github-project/project-visible-table-cache.ts src/renderer/src/components/github-project/project-visible-table-cache.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/github-project/project-visible-table-cache.test.ts src/renderer/src/components/github-project/project-row-filtering.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3188 | `nwparker/react-perf-feature-wall-persisted-reset` | Feature-wall persisted close reset Effect removed | Medium | Open | `pnpm exec oxlint src/renderer/src/components/feature-wall/use-persisted-feature-wall-completion.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 923 -> 922. | -| #3192 | `nwparker/react-perf-smart-name-command-value` | Smart workspace command highlight resolves during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx src/renderer/src/components/new-workspace/smart-workspace-command-value.ts src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3195 | `nwparker/react-perf-workspace-cleanup-view` | Workspace cleanup active view resolves during render | Medium | Open | `pnpm exec oxlint src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx src/renderer/src/components/workspace-cleanup/workspace-cleanup-view-selection.ts src/renderer/src/components/workspace-cleanup/workspace-cleanup-view-selection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/workspace-cleanup/workspace-cleanup-view-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3198 | `nwparker/react-perf-onboarding-settings-hydration` | Onboarding settings hydration resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/onboarding/use-onboarding-flow.ts src/renderer/src/components/onboarding/onboarding-settings-hydration.ts src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts src/renderer/src/components/onboarding/agent-picked-payload.test.ts src/renderer/src/components/onboarding/ThemeStep.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3200 | `nwparker/react-perf-resource-space-ready` | Resource Manager Space scan-ready latch resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx src/renderer/src/components/status-bar/resource-usage-space-scan-ready.ts src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3202 | `nwparker/react-perf-commit-failure-dialog` | Source Control commit-failure dialog identity resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/SourceControl.tsx src/renderer/src/components/right-sidebar/commit-failure-dialog-state.ts src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts src/renderer/src/components/right-sidebar/CommitArea.test.tsx src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3206 | `nwparker/react-perf-diff-comments-clear` | Source Control diff-comments clear confirmation resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/SourceControl.tsx src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.ts src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3210 | `nwparker/react-perf-diff-comments-copy-timers` | Source Control diff-notes copy reset timers move into copy handlers | Medium | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/SourceControl.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 921. | -| #3212 | `nwparker/react-perf-source-selection-reconcile` | Source Control stale selected-key pruning resolves before commit | High | Open | `pnpm exec oxlint src/renderer/src/components/right-sidebar/useSourceControlSelection.ts src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3214 | `nwparker/react-perf-project-dialog-state` | GitHub Project dialog state resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/github-project/ProjectViewWrapper.tsx src/renderer/src/components/github-project/project-dialog-state.ts src/renderer/src/components/github-project/project-dialog-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/github-project/project-dialog-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 921. | -| #3218 | `nwparker/react-perf-combined-diff-copy-timer` | Combined diff note-copy reset timer moves into the copy handler | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/CombinedDiffViewer.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3220 | `nwparker/react-perf-app-lazy-modals` | App lazy-modal mount state resolves before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/App.tsx src/renderer/src/lazy-modal-mount-state.ts src/renderer/src/lazy-modal-mount-state.test.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lazy-modal-mount-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 922. | -| #3223 | `nwparker/react-perf-mobile-modal-resets` | Mobile shared modal draft resets move out of Effects | Low | Merged | `pnpm exec oxlint mobile/src/components/TextInputModal.tsx mobile/src/components/CustomKeyModal.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 923 -> 921. Mobile package-local typecheck blocked by missing Expo/mobile deps/config in this checkout. | -| #3226 | `nwparker/react-perf-bottom-drawer-mounted` | Mobile BottomDrawer mounted state resolves before commit | Medium | Open | `pnpm exec oxlint mobile/src/components/BottomDrawer.tsx mobile/src/components/bottom-drawer-mount-state.ts mobile/src/components/bottom-drawer-mount-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `pnpm exec tsx -e "import { strict as assert } from 'node:assert'; import { resolveBottomDrawerMounted } from './mobile/src/components/bottom-drawer-mount-state.ts'; assert.equal(resolveBottomDrawerMounted(true, false), true); assert.equal(resolveBottomDrawerMounted(true, true), true); assert.equal(resolveBottomDrawerMounted(false, true), true); assert.equal(resolveBottomDrawerMounted(false, false), false); console.log('bottom-drawer-mount-state ok')"`; AST Effect count 921 -> 920. Mobile Vitest blocked by missing `expo/tsconfig.base` in this checkout. | -| #3228 | `nwparker/react-perf-mobile-browser-address` | Mobile browser address sync resolves before commit | Medium | Open | `pnpm exec oxlint mobile/src/browser/MobileBrowserPane.tsx mobile/src/browser/mobile-browser-address-sync.ts mobile/src/browser/mobile-browser-address-sync.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `pnpm exec tsx -e "import { strict as assert } from 'node:assert'; import { resolveMobileBrowserAddressSync } from './mobile/src/browser/mobile-browser-address-sync.ts'; assert.deepEqual(resolveMobileBrowserAddressSync({ focused: false, url: 'https://old.example/' }, { focused: false, url: 'https://new.example/' }), { nextState: { focused: false, url: 'https://new.example/' }, shouldSyncValue: true }); assert.deepEqual(resolveMobileBrowserAddressSync({ focused: false, url: 'https://old.example/' }, { focused: true, url: 'https://new.example/' }), { nextState: { focused: true, url: 'https://new.example/' }, shouldSyncValue: false }); assert.deepEqual(resolveMobileBrowserAddressSync({ focused: true, url: 'https://new.example/' }, { focused: false, url: 'https://new.example/' }), { nextState: { focused: false, url: 'https://new.example/' }, shouldSyncValue: true }); const previous = { focused: false, url: 'https://new.example/' }; assert.deepEqual(resolveMobileBrowserAddressSync(previous, { focused: false, url: 'https://new.example/' }), { nextState: previous, shouldSyncValue: false }); console.log('mobile-browser-address-sync ok')"`; AST Effect count 921 -> 920. Mobile Vitest blocked by missing `expo/tsconfig.base` in this checkout. | -| #3230 | `nwparker/react-perf-mobile-browser-refs` | Mobile browser ref mirrors move out of Effects | Medium | Open | `pnpm exec oxlint mobile/src/browser/MobileBrowserPane.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 921 -> 917. | -| #3234 | `nwparker/react-perf-mobile-dictation-refs` | Mobile dictation ref mirrors move out of Effects | Medium | Open | `pnpm exec oxlint mobile/src/hooks/use-mobile-dictation.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; AST Effect count 921 -> 920. | -| #3238 | `nwparker/react-perf-new-worktree-agent` | Mobile new-worktree agent selection resolves before commit | Medium | Open | `pnpm exec oxlint mobile/src/components/NewWorktreeModal.tsx mobile/src/components/new-worktree-agent-selection.ts mobile/src/components/new-worktree-agent-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; direct `pnpm exec tsx -e` assertions for new-worktree agent selection; AST Effect count 921 -> 919. Mobile Vitest and package-local typecheck blocked by missing mobile deps/config in this checkout. | -| #3240 | `nwparker/react-perf-pair-confirm-route` | Mobile pair-confirm route state resolves before commit | Medium | Open | `pnpm exec oxlint mobile/app/pair-confirm.tsx mobile/src/transport/pair-confirm-state.ts mobile/src/transport/pair-confirm-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; direct `pnpm exec tsx -e` assertions for pair-confirm route state; AST Effect count 921 -> 920. Mobile Vitest blocked by missing mobile deps/config in this checkout. | -| #3242 | `nwparker/react-perf-host-new-worktree-action` | Mobile host new-worktree route action resolves before commit | Medium | Open | `pnpm exec oxlint mobile/app/h/[hostId]/index.tsx mobile/src/host-route-action-state.ts mobile/src/host-route-action-state.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; direct `pnpm exec tsx -e` assertions for host route action state; AST Effect count 921 -> 920. Mobile Vitest blocked by missing mobile deps/config in this checkout. | -| #3244 | `nwparker/react-perf-mobile-accounts-client-ref` | Mobile Accounts unused client ref mirror removed | Low | Merged | `pnpm exec oxlint mobile/app/h/[hostId]/accounts.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 921 -> 920. | -| #3247 | `nwparker/react-perf-mobile-home-client-ref` | Mobile home shared-client ref mirror moves out of an Effect | Medium | Open | `pnpm exec oxlint mobile/app/index.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 919. | -| #3250 | `nwparker/react-perf-mobile-session-refs` | Mobile session terminal/tab ref mirrors move out of Effects | High | Open | `pnpm exec oxlint mobile/app/h/[hostId]/session/[worktreeId].tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 915. Mobile Vitest blocked by missing `expo/tsconfig.base` in this checkout. | -| #3253 | `nwparker/react-perf-mobile-tasks-client-ref` | Mobile Tasks client ref mirror moves out of an Effect | High | Open | `pnpm exec oxlint mobile/app/h/[hostId]/tasks.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 919. | -| #3255 | `nwparker/react-perf-github-assignee-reset` | GitHub assignee edit guard resets before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/PullRequestPage.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 918. | -| #3257 | `nwparker/react-perf-diff-viewer-autoscroll-ref` | DiffViewer first-diff auto-scroll reset moves out of a layout Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/editor/DiffViewer.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 919. | -| #3259 | `nwparker/react-perf-github-review-requests-sync` | GitHub reviewer request local-state sync moves out of Effects | Medium | Open | `pnpm exec oxlint src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/PullRequestPage.tsx src/renderer/src/components/TaskPage.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 917. | -| #3261 | `nwparker/react-perf-mobile-task-agent-selection` | Mobile Tasks workspace-create agent selection resolves before commit | Medium | Open | `pnpm exec oxlint mobile/app/h/[hostId]/tasks.tsx mobile/src/tasks/workspace-agent-selection.ts mobile/src/tasks/workspace-agent-selection.test.ts`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; direct `pnpm exec tsx -e` assertions for workspace-agent selection; AST Effect count 920 -> 918. Mobile Vitest blocked by missing `expo/tsconfig.base` in this checkout. | -| #3267 | `nwparker/react-perf-sidebar-ssh-reconnect-prompt` | Sidebar SSH reconnect prompts resolve before commit | High | Open | `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeCard.tsx src/renderer/src/components/sidebar/WorktreeList.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 918. | -| #3269 | `nwparker/react-perf-floating-terminal-bounds` | Floating workspace first-open bounds normalize before commit | Medium | Open | `pnpm exec oxlint src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx`; `pnpm run typecheck:web`; `git diff --check origin/main...HEAD`; `git diff --check`; AST Effect count 920 -> 919. | -| #3271 | `nwparker/react-perf-dead-terminal-shell` | Unused terminal shell/shortcut/hook modules removed | Low | Merged | `pnpm exec oxlint src/renderer/src/components/terminal`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 920 -> 916. | -| #3273 | `nwparker/react-perf-dead-notifications-page` | Unused feature-wall orchestration notifications page removed | Low | Merged | `pnpm exec oxlint src/renderer/src/components/feature-wall/agents-orchestration src/renderer/src/components/feature-wall/AgentsOrchestrationVisual.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 916 -> 914. | -| #3275 | `nwparker/react-perf-browser-find-debounce` | BrowserFind folds debounced query state into the webview search Effect | Medium | Open | `pnpm exec oxlint src/renderer/src/components/browser-pane/BrowserFind.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3277 | `nwparker/react-perf-editor-copy-path-timer` | Editor path-copy toast timeout moves into the copy handler | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/EditorPanel.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/EditorPanel.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3281 | `nwparker/react-perf-feature-wall-skill-state` | Feature-wall setup-card installed-state notifications move out of child Effects | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/hooks/useInstalledAgentSkills.ts src/renderer/src/components/settings/OrchestrationSetupCard.tsx src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx src/renderer/src/components/feature-wall/FeatureWallBody.tsx src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm exec oxlint src/renderer/src/hooks/useInstalledAgentSkills.ts src/renderer/src/components/settings/OrchestrationSetupCard.tsx src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx src/renderer/src/components/feature-wall/FeatureWallBody.tsx src/renderer/src/components/feature-wall/FeatureWallTourPanel.tsx src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 912. | -| #3283 | `nwparker/react-perf-feature-wall-session-depth` | Feature-wall session-depth open reset and publish Effects collapse into one Effect | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts`; `pnpm exec oxlint src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3285 | `nwparker/react-perf-github-link-copy-timer` | GitHub issue/PR link-copy feedback timers move into copy handlers | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/PullRequestPage.tsx`; `pnpm exec oxlint src/renderer/src/components/GitHubItemDialog.tsx src/renderer/src/components/PullRequestPage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 912. | -| #3287 | `nwparker/react-perf-shared-reduced-motion-hook` | Reduced-motion media-query subscription shared across renderer surfaces | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/hooks/usePrefersReducedMotion.ts src/renderer/src/components/feature-wall/feature-wall-modal-helpers.ts src/renderer/src/components/UpdateCard.tsx src/renderer/src/components/pet/PetOverlay.tsx`; `pnpm exec oxlint src/renderer/src/hooks/usePrefersReducedMotion.ts src/renderer/src/components/feature-wall/feature-wall-modal-helpers.ts src/renderer/src/components/UpdateCard.tsx src/renderer/src/components/pet/PetOverlay.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/UpdateCard.test.ts src/renderer/src/components/pet/pet-overlay-visibility.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 912. | -| #3289 | `nwparker/react-perf-pdf-find-open-reset` | PDF find open-state focus/reset moves out of a passive Effect | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/PdfFind.tsx src/renderer/src/components/editor/PdfViewer.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/PdfFind.tsx src/renderer/src/components/editor/PdfViewer.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3292 | `nwparker/react-perf-task-gitlab-filter` | GitLab task filter validity resolves before commit | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/TaskPage.tsx`; `pnpm exec oxlint src/renderer/src/components/TaskPage.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3294 | `nwparker/react-perf-nested-repo-indeterminate` | Nested repo select-all indeterminate checkbox state moves to a callback ref | Low | Merged | `pnpm exec oxfmt --write src/renderer/src/components/repo/NestedRepoTreePreview.tsx`; `pnpm exec oxlint src/renderer/src/components/repo/NestedRepoTreePreview.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 914 -> 913. | -| #3296 | `nwparker/react-perf-worktree-title-focus-ref` | Workspace title rename input focus moves to callback ref | Low | Merged | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 913 -> 912. | -| #3298 | `nwparker/react-perf-worktree-title-editing-change` | Workspace title rename editing notifications move to handlers | Low | Merged | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3300 | `nwparker/react-perf-worktree-meta-textarea-resize` | Worktree metadata notes textarea resizes from ref and input handlers | Low | Merged | `pnpm exec oxfmt --write src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx`; `pnpm exec oxlint src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 911 -> 910. | -| #3303 | `nwparker/react-perf-editor-tab-rename-focus-ref` | Editor file-tab rename input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/tab-bar/EditorFileTab.tsx src/renderer/src/components/tab-bar/EditorFileTab.test.tsx`; `pnpm exec oxlint src/renderer/src/components/tab-bar/EditorFileTab.tsx src/renderer/src/components/tab-bar/EditorFileTab.test.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/tab-bar/EditorFileTab.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 910 -> 909. | -| #3311 | `nwparker/react-perf-tab-entry-selection-reset` | Tab entry selected action resets during render | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx`; `pnpm exec oxlint src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3314 | `nwparker/react-perf-terminal-tab-rename-focus-ref` | Terminal tab rename input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/tab-bar/SortableTab.tsx`; `pnpm exec oxlint src/renderer/src/components/tab-bar/SortableTab.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3316 | `nwparker/react-perf-link-bubble-focus-ref` | Rich markdown link bubble input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/rich-markdown-key-handler.test.ts src/renderer/src/components/editor/markdown-internal-links.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3318 | `nwparker/react-perf-image-viewer-preview-src` | Image viewer preview source derives during render | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/ImageViewer.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/ImageViewer.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | -| #3322 | `nwparker/react-perf-markdown-preview-search-focus-ref` | Markdown preview search input focus moves to callback ref | Medium | Open | `pnpm exec oxfmt --write src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec oxlint src/renderer/src/components/editor/MarkdownPreview.tsx`; `pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/markdown-internal-links.test.ts src/renderer/src/components/editor/markdown-preview-links.test.ts src/renderer/src/components/editor/markdown-preview-url-transform.test.ts src/renderer/src/components/editor/markdown-doc-links.test.ts`; `pnpm run typecheck:web`; `git diff --check`; AST Effect count 912 -> 911. | - -## Reproduction Commands - -Run from the worktree root. - -```bash -rg --files -g '*.tsx' -g '*.ts' | wc -l -rg -l "\\b(useEffect|useLayoutEffect|React\\.useEffect|React\\.useLayoutEffect)\\b" -g '*.tsx' -g '*.ts' | sort -``` - -The exact Effect-site counts above came from a TypeScript AST scan so comment-only mentions are not counted. diff --git a/docs/remote-web-paste-activity-fixes.md b/docs/remote-web-paste-activity-fixes.md new file mode 100644 index 00000000000..dca8ee5bed6 --- /dev/null +++ b/docs/remote-web-paste-activity-fixes.md @@ -0,0 +1,108 @@ +# Remote Web Paste And Activity Fixes + +## Problem + +- Paired browser image paste reads a clipboard image in `src/renderer/src/web/web-preload-api.ts:120`, converts it to PNG base64, and sends the whole string through `clipboard.saveImageAsTempFile` at `src/renderer/src/web/web-preload-api.ts:1613`. `src/main/runtime/rpc/ws-transport.ts` sets `maxPayload` to 1 MiB, and the browser RPC client encrypts JSON requests into base64 text frames. A screenshot well below the existing 24 MiB clipboard schema limit can still exceed the encrypted WebSocket frame cap and close the socket as `Remote Orca runtime connection interrupted`. +- Paired browser workspace activity is inflated. `src/renderer/src/lib/worktree-status.ts` and `src/renderer/src/lib/worktree-activity-state.ts` currently treat any mirrored web terminal surface ID as active, even when `src/renderer/src/runtime/web-session-tabs-sync.ts` only has a pending host terminal with no ready PTY handle. + +## Root Cause + +- The clipboard RPC accepts up to 24 MiB of base64 in `src/main/runtime/rpc/methods/clipboard.ts`, but the web transport limit applies before the server can validate RPC params. Because the web client encrypts JSON and base64-encodes the encrypted bytes, the safe plaintext chunk is materially smaller than 1 MiB. The existing 512 KiB file-upload chunk size in `src/renderer/src/runtime/runtime-file-client.ts` is safe after JSON plus encryption expansion; a chunk close to 1 MiB is not. +- The file-upload RPCs cannot be reused directly. They write worktree-relative files and then commit a rename; clipboard paste must call `saveClipboardImageBufferAsTempFile`, return a local or SSH temp path, and preserve the terminal repo's `connectionId`. +- Web session-tab sync intentionally mirrors pending host terminal surfaces so the tab model stays in parity, but it writes `ptyIdsByTabId[mirroredTabId]` only from `status: "ready"` surfaces. The status/filter helpers then bypass that liveness map with `isWebTerminalSurfaceTabId`, so pending mirrors look active. + +## Non-Goals + +- Do not raise the global WebSocket `maxPayload`; it protects all runtime traffic, including pre-auth and mobile sockets. +- Do not change sidebar grouping, labels, counts, pairing flow, terminal UI, or clipboard permission UI. +- Do not change local Electron clipboard behavior or the existing one-shot RPC contract. + +## Design + +1. Add bounded chunked clipboard-image RPCs. + - Keep `clipboard.saveImageAsTempFile` for small/old clients. + - Add `clipboard.startImageUpload`, `clipboard.appendImageUploadChunk`, `clipboard.commitImageUpload`, and `clipboard.abortImageUpload`. + - `start` takes `expectedBase64Length` and `connectionId`, rejects values over the existing 24 MiB base64 limit, records the target connection, and returns an unguessable upload ID. + - `append` takes `uploadId`, `offset`, and `contentBase64`; reject unknown IDs, out-of-order offsets, chunks above 512 KiB, invalid base64 characters, and cumulative length beyond `expectedBase64Length`. + - `commit` verifies the received length equals `expectedBase64Length`, validates the full base64 payload with the same rules as the one-shot RPC, calls `saveClipboardImageBufferAsTempFile` with the recorded `connectionId`, and deletes the upload state in `finally`. + - `abort` deletes the upload state and is idempotent. + - Bound server memory with a small max concurrent upload count and a TTL cleanup for abandoned uploads. Upload state is process-local; reconnects restart the paste instead of resuming. + +2. Switch paired browser image paste to the chunked path. + - After `navigator.clipboard.read()` and PNG conversion, return `null` for no image as today. + - Preflight `contentBase64.length` against the 24 MiB limit before starting an upload. + - Send 512 KiB base64 slices. This is below the 1 MiB encrypted frame cap after JSON and E2EE base64 expansion. + - Abort best-effort on append or commit failure. If `start` returns `method_not_found`, fall back to `clipboard.saveImageAsTempFile` only when the payload is below a conservative single-frame threshold; never send a large fallback frame. + +3. Tighten workspace activity liveness. + - Treat terminal workspaces as active only when `tabHasLivePty(ptyIdsByTabId, tab.id)` is true. + - Remove the blanket `isWebTerminalSurfaceTabId` active shortcut from `getWorktreeStatus` and `hasActiveWorkspaceActivity`. + - Keep browser tabs active without terminals. + - Keep fresh/retained explicit agent rows able to promote status to `permission`, `working`, or `done`; this is separate from terminal liveness. + +## Data Flow + +- Paste image: + - Browser paste command -> `navigator.clipboard.read()` -> PNG base64 in web preload memory. + - `clipboard.startImageUpload({ expectedBase64Length, connectionId })` -> upload ID. + - Repeated `clipboard.appendImageUploadChunk({ uploadId, offset, contentBase64 })`. + - `clipboard.commitImageUpload({ uploadId })` -> runtime saves temp image locally or on the SSH target -> terminal receives the temp path. + +- Workspace activity: + - Host `session.tabs.listAll` or subscription snapshot includes terminal surfaces. + - Web sync mirrors tabs but writes live PTY handles only for ready surfaces. + - Sidebar status/filter reads `ptyIdsByTabId` and browser tabs. + - Pending mirrored terminals with no PTY remain visible but do not count as active. + +## Edge Cases + +- Clipboard has no image or browser lacks `navigator.clipboard.read`: return `null`, no upload session. +- Clipboard read/permission/conversion fails: existing terminal paste error path reports the failure; the runtime socket should stay open. +- Non-PNG clipboard images may grow during PNG conversion; validate the post-conversion base64 length. +- Image exceeds 24 MiB base64: reject before upload and enforce again on the server. +- Chunk boundaries must preserve base64 validity; the 512 KiB chunk size is divisible by 4, and the final full payload validation catches padding errors. +- Append retry or concurrent append with the same upload ID: offset validation rejects duplicate, skipped, or out-of-order data. +- Multiple paired browser clients or windows paste at once: upload IDs isolate sessions and the concurrent-upload cap bounds memory. +- Append or commit fails: browser aborts best-effort; server TTL cleans abandoned state. +- SSH connection missing or drops during commit: commit fails, upload state is still deleted, and the paste path reports the error. +- Runtime restarts during upload: pending RPC fails; no resume is attempted. +- Pending host terminal surface later becomes ready: the next snapshot writes PTY handles and the workspace becomes active. +- Host closes a terminal or browser tab: the next snapshot removes the mirrored tab and stale PTY/browser handles before activity is recomputed. + +## Test Plan + +- Unit: `src/main/runtime/rpc/methods/clipboard.test.ts` for start/append/commit/abort, offset validation, invalid base64, size limit, TTL cleanup, commit cleanup on save failure, SSH `connectionId` forwarding, and concurrent upload isolation. +- Unit: `src/renderer/src/web/web-preload-api.test.ts` for chunk sequencing, 512 KiB max chunks, `method_not_found` small-payload fallback, no large fallback frame, and abort on append/commit failure. +- Unit: `src/renderer/src/lib/worktree-status.test.ts` and `src/renderer/src/lib/worktree-activity-state.test.ts` for pending mirrored terminal inactivity, ready mirrored terminal activity, browser-only activity, and explicit agent-row promotion. +- Focused run: `pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/clipboard.test.ts src/renderer/src/web/web-preload-api.test.ts src/renderer/src/lib/worktree-status.test.ts src/renderer/src/lib/worktree-activity-state.test.ts`. +- Type/lint: `pnpm typecheck`, `pnpm lint`. +- Paired-client validation: restart the host app, pair a real browser client, paste a screenshot large enough that the old one-shot RPC exceeded the 1 MiB encrypted frame cap into a local terminal and an SSH-backed terminal, confirm the runtime socket stays connected, then compare browser and host sidebar activity. + +## UI Quality Bar + +No layout changes. The paired browser sidebar should show active dots only for workspaces with live terminal PTYs, browser tabs, or explicit agent rows. Image paste should insert the generated temp image path into the terminal without connection-error toasts or DevTools runtime disconnect errors. + +## Review Screenshots + +1. Paired browser sidebar after hydration with inactive Done/Todo workspaces visible but not marked active. +2. Paired browser terminal after image paste, showing the generated temp image path inserted. +3. Host sidebar for the same session, showing matching active workspace state. + +## Rollout + +1. Add chunked clipboard RPC implementation and tests. +2. Switch web preload image paste to chunked upload and tests. +3. Tighten activity/status liveness helpers and tests. +4. Run focused tests, typecheck, and lint. +5. Validate in a paired Electron/browser session, including SSH paste, and capture the review screenshots. + +## Lightweight Eng Review + +- Scope: narrow to web clipboard transport and activity liveness. No global WebSocket limit, sidebar redesign, or local Electron clipboard changes. +- Architecture/data flow: web preload owns browser clipboard read and chunk sequencing; main runtime clipboard RPC owns upload session state and final temp-file save; sidebar helpers stay pure and consume the existing live-PTY map. +- Failure modes: oversized images must fail before a large frame is sent; abandoned uploads expire; failed append/commit paths clean up; pending host terminal mirrors do not inflate activity; SSH commit failures do not leak upload state. +- Tests: cover RPC lifecycle and bounds, web preload sequencing/fallback/abort, status/filter liveness, and real paired-client paste/sidebar parity. +- Performance/blast radius: chunking is not free. A max-size image is dozens of serialized runtime RPC calls and duplicates base64 in browser and main memory. The impact is limited to image paste by TTL, size, and concurrency caps. Activity changes affect sidebar filters, jump palette activity, and status dots. +- UI quality: no new chrome. Judge only sidebar parity, paste result, and absence of runtime disconnect/toast regressions. +- Screenshots: browser sidebar, browser terminal after paste, and matching host sidebar. +- Residual risk: clipboard permission and image conversion behavior are browser-dependent, so paired browser validation is required in addition to unit tests. diff --git a/mobile/app.json b/mobile/app.json index 57e43ffdd01..7d77ed5341d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -3,7 +3,7 @@ "name": "Orca", "slug": "orca-mobile", "version": "0.0.10", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", "scheme": "orca", @@ -14,7 +14,7 @@ "backgroundColor": "#111111" }, "ios": { - "supportsTablet": false, + "supportsTablet": true, "bundleIdentifier": "com.stably.orca.mobile", "buildNumber": "1", "infoPlist": { diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 188d52fe604..e6e091edf72 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -10,6 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationPath } from '../src/notifications/notification-routing' import { loadHosts } from '../src/transport/host-store' +import { extractPairingCodeFromUrl } from '../src/transport/pairing' // Why: keeps the native splash screen visible until the React tree is mounted // and ready to render. Without this the user sees a blank white/black frame @@ -30,39 +31,23 @@ Notifications.setNotificationHandler({ }) }) -// Why: extract the path+payload that follows the orca://pair anchor so we -// can route it to the confirm screen. Accept either a hash payload -// (`orca://pair#`, the QR / shared form) or a query param -// (`orca://pair?code=<...>`, future-proof for share sheets that strip -// fragments). -function extractPairCode(url: string): string | null { - if (!url.startsWith('orca://pair')) return null - const hashIndex = url.indexOf('#') - if (hashIndex !== -1) { - return url.slice(hashIndex + 1) || null - } - const queryIndex = url.indexOf('?') - if (queryIndex !== -1) { - const params = new URLSearchParams(url.slice(queryIndex + 1)) - return params.get('code') - } - return null -} - export default function RootLayout() { const router = useRouter() const handledNotificationIdsRef = useRef>(new Set()) - // Why: route `orca://pair#` deep links to the confirm screen so + // Why: route `orca://pair?...` deep links to the confirm screen so // the same pairing flow runs whether the link arrived via QR scan, // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL // covers cold-start (link tapped while app was closed); the listener // covers warm-start (link tapped while app is in memory). useEffect(() => { function handleUrl(url: string) { - const code = extractPairCode(url) + const code = extractPairingCodeFromUrl(url) if (code) { - router.push({ pathname: '/pair-confirm', params: { code } }) + // Why: Android camera launches can leave Expo Router's unmatched + // `orca://pair` route underneath this screen; replacing keeps cancel + // and edge-back from revealing the router error page. + router.replace({ pathname: '/pair-confirm', params: { code } }) } } @@ -169,6 +154,7 @@ export default function RootLayout() { }} /> + diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 194bbcd4a04..fc4765448c6 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -54,6 +54,7 @@ import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' import { getCachedWorktrees } from '../../../src/cache/worktree-cache' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { useResponsiveLayout } from '../../../src/layout/responsive-layout' import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat' import { loadPinnedIds, @@ -302,6 +303,9 @@ export default function HostScreen() { const { hostId, action } = useLocalSearchParams<{ hostId: string; action?: string }>() const router = useRouter() const insets = useSafeAreaInsets() + // Why: cap and center the worktree list on wide/tablet canvases; on phones + // isWideLayout is false so the list stays edge-to-edge as before. + const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [initialCache] = useState(() => hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null ) @@ -956,7 +960,11 @@ export default function HostScreen() { // Why: edge-to-edge — the list scrolls under the system nav bar // while reserving insets.bottom keeps the last worktree row reachable // above the Samsung 3-button nav / iOS home indicator. - contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + contentContainerStyle={[ + styles.list, + { paddingBottom: spacing.lg + insets.bottom }, + isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } + ]} renderSectionHeader={({ section }) => { if (!section.title) return null const isCollapsed = collapsedGroups.has(section.title) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5bd76d65e03..26d42bab2cc 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -18,13 +18,15 @@ import { type TextStyle } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import AsyncStorage from '@react-native-async-storage/async-storage' import { AlertTriangle, ArrowUp, + Bot, ChevronLeft, ChevronRight, + ChevronsRight, Eraser, Folder, File, @@ -59,7 +61,11 @@ import { type TerminalModes, type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' -import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys' +import { + getDefaultTerminalAccessoryBuiltInIds, + getVisibleTerminalAccessoryKeys, + loadTerminalAccessoryLayout +} from '../../../../src/terminal/terminal-accessory-layout' import { getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit @@ -98,6 +104,11 @@ import { terminalRecordsEqual, type TerminalRecord } from '../../../../src/session/mobile-terminal-records' +import { + buildMobileNewTabAgentOptions, + type MobileNewTabAgentOption, + type MobileNewTabAgentSettings +} from '../../../../src/session/mobile-new-tab-agent-options' import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' type Terminal = TerminalRecord @@ -294,6 +305,20 @@ type TerminalCreateResult = { tab: Extract } +type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error' + +type RuntimeRepoSummary = { + id: string + connectionId?: string | null +} + +function getRepoIdFromMobileWorktreeId(id: string): string { + // Why: mobile cannot import desktop shared modules in its standalone tsc run, + // but the runtime worktree id wire format is still `${repoId}::${path}`. + const separatorIdx = id.indexOf('::') + return separatorIdx === -1 ? id : id.slice(0, separatorIdx) +} + type MobileDisplayMode = 'auto' | 'phone' | 'desktop' const STATUS_LABELS: Record = { @@ -327,6 +352,10 @@ function isWheelMouseTrackingMode(mode: TerminalModes['mouseTrackingMode'] | und return mode === 'vt200' || mode === 'drag' || mode === 'any' } +function isGestureMouseTrackingMode(mode: TerminalModes['mouseTrackingMode'] | undefined): boolean { + return mode === 'x10' || isWheelMouseTrackingMode(mode) +} + function TerminalPaneView({ handle, active, @@ -703,6 +732,9 @@ export default function SessionScreen() { const [createError, setCreateError] = useState('') const [createWarning, setCreateWarning] = useState(initialCreateWarning) const [showCreateTabDrawer, setShowCreateTabDrawer] = useState(false) + const [createTabAgentLoadState, setCreateTabAgentLoadState] = + useState('idle') + const [createTabAgentOptions, setCreateTabAgentOptions] = useState([]) const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) const [markdownActionTarget, setMarkdownActionTarget] = useState(null) const [renameTarget, setRenameTarget] = useState(null) const [customKeys, setCustomKeys] = useState([]) + const [visibleBuiltInIds, setVisibleBuiltInIds] = useState( + getDefaultTerminalAccessoryBuiltInIds + ) const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) const [deleteKeyTarget, setDeleteKeyTarget] = useState(null) + const visibleBuiltInAccessoryKeys = useMemo( + () => getVisibleTerminalAccessoryKeys(visibleBuiltInIds), + [visibleBuiltInIds] + ) // Why: in Expo SDK 55 edge-to-edge mode the OS does NOT resize the window when // the IME opens — the keyboard draws on top of the app. We track the keyboard // height ourselves and translate the input/accessory area above the IME without @@ -1716,6 +1755,34 @@ export default function SessionScreen() { void loadCustomKeys().then(setCustomKeys) }, []) + useFocusEffect( + useCallback(() => { + let stale = false + void loadTerminalAccessoryLayout().then((layout) => { + if (!stale) setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + return () => { + stale = true + } + }, []) + ) + + useEffect(() => { + let mounted = true + const refresh = () => { + void loadTerminalAccessoryLayout().then((layout) => { + if (mounted) setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + } + const sub = AppState.addEventListener('change', (s: AppStateStatus) => { + if (s === 'active') refresh() + }) + return () => { + mounted = false + sub.remove() + } + }, []) + // Why: re-measure when non-keyboard layout-affecting state changes // (e.g. tab strip toggling visibility when the terminal count crosses // 0↔1 — without this, a freshly-created 2nd tab subscribes with a @@ -1815,6 +1882,11 @@ export default function SessionScreen() { [customKeys] ) + const handleManageShortcuts = useCallback(() => { + setShowCustomKeyModal(false) + router.push('/terminal-settings') + }, [router]) + useEffect(() => { clearTerminalCache() activeHandleRef.current = null @@ -2401,9 +2473,9 @@ export default function SessionScreen() { if (handle !== activeHandleRef.current || activeSessionTabTypeRef.current !== 'terminal') return const modes = ptyModesRef.current.get(handle) - // Why: WebView messages can become PTY input here. Only TUI scroll paths - // generate gesture input, and the bridge is rate-limited for SSH safety. - if (!modes?.altScreen && !isWheelMouseTrackingMode(modes?.mouseTrackingMode)) return + // Why: WebView gesture bytes can become PTY input here, so mouse-aware + // reports stay behind validation and SSH-safe rate limiting. + if (!modes?.altScreen && !isGestureMouseTrackingMode(modes?.mouseTrackingMode)) return const sequenceCount = countTerminalGestureInputSequences(bytes) if (sequenceCount == null) return if (!allowTerminalGestureInput(handle, sequenceCount)) return @@ -2610,7 +2682,74 @@ export default function SessionScreen() { } }, [selectModeActive]) - async function handleCreateTerminal() { + useEffect(() => { + if (!showCreateTabDrawer) { + setCreateTabAgentLoadState('idle') + setCreateTabAgentOptions([]) + return + } + if (!client || connState !== 'connected') { + setCreateTabAgentLoadState('idle') + setCreateTabAgentOptions([]) + return + } + + let stale = false + setCreateTabAgentLoadState('loading') + setCreateTabAgentOptions([]) + + void (async () => { + const [settingsResponse, repoResponse] = await Promise.all([ + client.sendRequest('settings.get'), + client.sendRequest('repo.list') + ]) + if (!settingsResponse.ok) { + throw new Error(settingsResponse.error.message) + } + const settings = ( + (settingsResponse as RpcSuccess).result as { + settings?: MobileNewTabAgentSettings + } + ).settings + if (!repoResponse.ok) { + throw new Error(repoResponse.error.message) + } + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + if (!repoId) { + throw new Error('worktree_repo_missing') + } + const repos = + ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? [] + const repo = repos.find((candidate) => candidate.id === repoId) + if (!repo) { + throw new Error('worktree_repo_not_found') + } + const connectionId = repo.connectionId?.trim() || null + const detectedResponse = connectionId + ? await client.sendRequest('preflight.detectRemoteAgents', { connectionId }) + : await client.sendRequest('preflight.detectAgents') + if (!detectedResponse.ok) { + throw new Error(detectedResponse.error.message) + } + if (stale) { + return + } + const detectedAgentIds = (detectedResponse as RpcSuccess).result as unknown[] + setCreateTabAgentOptions(buildMobileNewTabAgentOptions(settings, detectedAgentIds)) + setCreateTabAgentLoadState('loaded') + })().catch(() => { + if (!stale) { + setCreateTabAgentOptions([]) + setCreateTabAgentLoadState('error') + } + }) + + return () => { + stale = true + } + }, [client, connState, showCreateTabDrawer, worktreeId]) + + async function handleCreateTerminal(agent?: MobileNewTabAgentOption['agent']) { if (!client || creating) return setCreating(true) @@ -2619,7 +2758,8 @@ export default function SessionScreen() { try { const response = await client.sendRequest('session.tabs.createTerminal', { worktree: `id:${worktreeId}`, - afterTabId: activeSessionTabId ?? undefined + afterTabId: activeSessionTabId ?? undefined, + ...(agent ? { agent } : {}) }) if (response.ok) { const result = (response as RpcSuccess).result as TerminalCreateResult @@ -2951,6 +3091,47 @@ export default function SessionScreen() { opacity: toastOpacityRef.current, transform: [{ translateY: -keyboardLift }] } + const createTabAgentActions = + createTabAgentLoadState === 'loading' + ? [ + { + label: 'Detecting Agents', + icon: Bot, + disabled: true, + loading: true, + onPress: () => {} + } + ] + : createTabAgentOptions.length > 0 + ? createTabAgentOptions.map((option) => ({ + label: option.label, + hint: 'Agent preset', + icon: Bot, + onPress: () => { + setShowCreateTabDrawer(false) + void handleCreateTerminal(option.agent) + } + })) + : createTabAgentLoadState === 'loaded' + ? [ + { + label: 'No Enabled Agents', + icon: Bot, + disabled: true, + onPress: () => {} + } + ] + : createTabAgentLoadState === 'error' + ? [ + { + label: 'Agent Presets Unavailable', + hint: 'Check the host connection', + icon: Bot, + disabled: true, + onPress: () => {} + } + ] + : [] return ( @@ -3279,15 +3460,16 @@ export default function SessionScreen() { : 'Switch to live terminal input' } > - - Live - + {canPaste && ( )} - {TERMINAL_ACCESSORY_KEYS.map((key) => ( + {visibleBuiltInAccessoryKeys.map((key) => ( [ styles.accessoryKey, pressed && styles.accessoryKeyPressed, @@ -3383,12 +3565,9 @@ export default function SessionScreen() { onPress={focusLiveInput} accessibilityLabel="Focus live terminal input" > - - - Live - + - Keyboard input goes to terminal + Keyboard input directly goes to terminal setShowCreateTabDrawer(false)} /> @@ -3775,6 +3955,7 @@ export default function SessionScreen() { visible={showCustomKeyModal} onClose={() => setShowCustomKeyModal(false)} onKeysChanged={setCustomKeys} + onManageShortcuts={handleManageShortcuts} /> { + // Why: task-loading effects use this as a stale-client guard, so the ref + // must be current before those passive effects can run after commit. + useLayoutEffect(() => { clientRef.current = client }, [client]) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e0d13d1cd88..3486fd441bc 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -48,6 +48,7 @@ import { normalizeVisibleTaskProviders, type TaskProvider } from '../src/tasks/mobile-task-providers' +import { useResponsiveLayout } from '../src/layout/responsive-layout' function endpointLabel(endpoint: string): string { try { @@ -270,6 +271,9 @@ function repoColor(name: string): string { export default function HomeScreen() { const router = useRouter() const insets = useSafeAreaInsets() + // Why: cap and center content on wide/tablet canvases so cards don't stretch + // edge-to-edge on iPad; on phones isWideLayout is false and layout is unchanged. + const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [hosts, setHosts] = useState([]) const [actionTarget, setActionTarget] = useState(null) const [renameTarget, setRenameTarget] = useState(null) @@ -676,7 +680,13 @@ export default function HomeScreen() { {hosts.length === 0 ? ( /* ─── Empty state: onboarding ─── */ - + Connect your desktop @@ -712,7 +722,11 @@ export default function HomeScreen() { // Why: edge-to-edge — let the list scroll under the system nav bar // but reserve insets.bottom so the last row stays reachable above // the Samsung 3-button nav / iOS home indicator. - contentContainerStyle={[styles.list, { paddingBottom: spacing.xl + insets.bottom }]} + contentContainerStyle={[ + styles.list, + { paddingBottom: spacing.xl + insets.bottom }, + isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } + ]} ListHeaderComponent={ diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index 2a63018aafe..32decd09344 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from 'react' -import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native' +import { useCallback, useEffect, useRef, useState } from 'react' +import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft } from 'lucide-react-native' import { parsePairingCode } from '../src/transport/pairing' import { connect } from '../src/transport/rpc-client' @@ -32,6 +32,20 @@ export default function PairConfirmScreen() { // batch fewer setState calls when entries arrive in bursts. const logsRef = useRef([]) + const cancel = useCallback(() => { + router.replace('/') + }, [router]) + + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + cancel() + return true + }) + return () => subscription.remove() + }, [cancel]) + ) + useEffect(() => { if (!params.code) { setStatus('error') @@ -118,10 +132,6 @@ export default function PairConfirmScreen() { } } - function cancel() { - router.replace('/') - } - const containerPadding = { paddingTop: insets.top + spacing.sm } return ( diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index ca8a8673e7a..b64ea284a87 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -210,7 +210,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="orca://pair#... or paste the code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> @@ -306,7 +306,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="orca://pair#... or paste the code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> diff --git a/mobile/app/pair.tsx b/mobile/app/pair.tsx new file mode 100644 index 00000000000..6f3483763e1 --- /dev/null +++ b/mobile/app/pair.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { extractPairingCodeFromUrl } from '../src/transport/pairing' + +export default function PairRedirectScreen() { + const router = useRouter() + const params = useLocalSearchParams<{ code?: string }>() + const [missingCode, setMissingCode] = useState(false) + + const goHome = useCallback(() => { + router.replace('/') + }, [router]) + + useEffect(() => { + let disposed = false + + async function redirectToConfirm() { + const codeParam = Array.isArray(params.code) ? params.code[0] : params.code + if (codeParam) { + router.replace({ pathname: '/pair-confirm', params: { code: codeParam } }) + return + } + + const initialUrl = await Linking.getInitialURL().catch(() => null) + const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null + if (disposed) { + return + } + if (code) { + router.replace({ pathname: '/pair-confirm', params: { code } }) + return + } + setMissingCode(true) + } + + void redirectToConfirm() + return () => { + disposed = true + } + }, [params.code, router]) + + return ( + + {missingCode ? ( + <> + Missing pairing code + + Back to home + + + ) : ( + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + lineHeight: 20, + marginBottom: spacing.xl, + textAlign: 'center' + }, + primaryButton: { + alignItems: 'center', + backgroundColor: colors.textPrimary, + borderRadius: radii.button, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2 + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index abcf324a855..107ed53f925 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -1,14 +1,40 @@ -import { useEffect, useMemo, useState } from 'react' -import { View, Text, StyleSheet, Pressable, ScrollView } from 'react-native' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AppState, + View, + Text, + StyleSheet, + Pressable, + ScrollView, + Switch, + type AppStateStatus +} from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' -import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native' -import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { useFocusEffect, useRouter } from 'expo-router' +import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native' +import { + CustomKeyModal, + loadCustomKeys, + saveCustomKeys, + type CustomKey +} from '../src/components/CustomKeyModal' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useAllHostClients } from '../src/transport/client-context' import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' +import { + TERMINAL_ACCESSORY_KEYS, + type TerminalAccessoryKey +} from '../src/terminal/terminal-accessory-keys' +import { + getDefaultTerminalAccessoryBuiltInIds, + loadTerminalAccessoryLayout, + resetTerminalAccessoryBuiltInIds, + saveTerminalAccessoryLayout, + setTerminalAccessoryBuiltInVisible +} from '../src/terminal/terminal-accessory-layout' type RestoreValue = 'indefinite' | '60s' | '5m' | '30m' @@ -74,6 +100,33 @@ function HostFitRow({ ) } +function ShortcutBarRow({ + shortcutKey, + visible, + onToggle +}: { + shortcutKey: TerminalAccessoryKey + visible: boolean + onToggle: (visible: boolean) => void +}): React.JSX.Element { + return ( + + + {shortcutKey.label} + + + {shortcutKey.accessibilityLabel ?? shortcutKey.label} + + + + ) +} + export default function TerminalSettingsScreen() { const router = useRouter() const insets = useSafeAreaInsets() @@ -84,6 +137,9 @@ export default function TerminalSettingsScreen() { const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) const hostClients = useAllHostClients(hostIds) + const [customKeys, setCustomKeys] = useState([]) + const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) + // Why: per-host current value, lazily fetched. We keep state at the // screen level rather than per-row so the picker can render at root // level — embedding PickerModal inside a row clipped its BottomDrawer @@ -91,6 +147,79 @@ export default function TerminalSettingsScreen() { // drawer appear cut-off. const [hostMs, setHostMs] = useState>({}) const [pickerHostId, setPickerHostId] = useState(null) + const [visibleBuiltInIds, setVisibleBuiltInIds] = useState( + getDefaultTerminalAccessoryBuiltInIds + ) + const layoutWriteChainRef = useRef>(Promise.resolve()) + const layoutWriteSeqRef = useRef(0) + const pendingLayoutWritesRef = useRef(0) + + const persistLayout = useCallback((nextIds: string[]) => { + layoutWriteSeqRef.current += 1 + pendingLayoutWritesRef.current += 1 + layoutWriteChainRef.current = layoutWriteChainRef.current + .catch(() => {}) + .then(() => saveTerminalAccessoryLayout(nextIds)) + .catch(() => {}) + .finally(() => { + pendingLayoutWritesRef.current -= 1 + }) + }, []) + + const refreshShortcutLayout = useCallback(() => { + const refreshSeq = layoutWriteSeqRef.current + void loadTerminalAccessoryLayout().then((layout) => { + if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) return + setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + }, []) + + const refreshCustomKeys = useCallback(() => { + void loadCustomKeys().then(setCustomKeys) + }, []) + + const handleDeleteCustomKey = useCallback( + async (key: CustomKey) => { + const updated = customKeys.filter((k) => k.id !== key.id) + setCustomKeys(updated) + await saveCustomKeys(updated) + }, + [customKeys] + ) + + useFocusEffect( + useCallback(() => { + refreshShortcutLayout() + refreshCustomKeys() + }, [refreshShortcutLayout, refreshCustomKeys]) + ) + + useEffect(() => { + const sub = AppState.addEventListener('change', (s: AppStateStatus) => { + if (s === 'active') { + refreshShortcutLayout() + refreshCustomKeys() + } + }) + return () => sub.remove() + }, [refreshShortcutLayout, refreshCustomKeys]) + + const toggleBuiltInKey = useCallback( + (id: string, visible: boolean) => { + setVisibleBuiltInIds((current) => { + const next = setTerminalAccessoryBuiltInVisible(current, id, visible) + persistLayout(next) + return next + }) + }, + [persistLayout] + ) + + const resetBuiltInKeys = useCallback(() => { + const next = resetTerminalAccessoryBuiltInIds() + setVisibleBuiltInIds(next) + persistLayout(next) + }, [persistLayout]) useEffect(() => { let cancelled = false @@ -143,6 +272,7 @@ export default function TerminalSettingsScreen() { } const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null + const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds]) return ( @@ -186,6 +316,76 @@ export default function TerminalSettingsScreen() { })} )} + + SHORTCUT BAR + + {TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => ( + + {idx > 0 && } + toggleBuiltInKey(shortcutKey.id, visible)} + /> + + ))} + + [styles.row, pressed && styles.rowPressed]} + onPress={resetBuiltInKeys} + > + + Reset Defaults + Show every built-in shortcut key + + + + + CUSTOM SHORTCUTS + + {customKeys.length === 0 ? ( + + No custom shortcuts defined yet. + + ) : ( + customKeys.map((key, idx) => ( + + {idx > 0 && } + + + {key.label} + + + {key.label} + + {key.bytes.replace(/\r/g, ' ↵')} + + + [ + styles.deleteButton, + pressed && styles.deleteButtonPressed + ]} + onPress={() => handleDeleteCustomKey(key)} + > + + + + + )) + )} + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setShowCustomKeyModal(true)} + > + + Add Custom Shortcut… + Create key combo or text macro + + + + @@ -198,6 +398,14 @@ export default function TerminalSettingsScreen() { }} onClose={() => setPickerHostId(null)} /> + + setShowCustomKeyModal(false)} + onKeysChanged={(keys) => { + setCustomKeys(keys) + }} + /> ) } @@ -239,6 +447,9 @@ const styles = StyleSheet.create({ marginBottom: spacing.xs, paddingHorizontal: spacing.xs }, + groupTopGap: { + marginTop: spacing.xl + }, groupDescription: { fontSize: typography.bodySize - 1, color: colors.textSecondary, @@ -247,7 +458,7 @@ const styles = StyleSheet.create({ }, section: { backgroundColor: colors.bgPanel, - borderRadius: 12, + borderRadius: radii.card, overflow: 'hidden' }, sectionTopGap: { @@ -281,9 +492,38 @@ const styles = StyleSheet.create({ color: colors.textSecondary, marginTop: 2 }, + keycap: { + minWidth: 62, + alignItems: 'center', + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + keycapText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: colors.borderSubtle, marginHorizontal: spacing.md + }, + emptyContainer: { + padding: spacing.md, + alignItems: 'center', + justifyContent: 'center' + }, + deleteButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(239, 68, 68, 0.1)' + }, + deleteButtonPressed: { + backgroundColor: 'rgba(239, 68, 68, 0.2)' } }) diff --git a/mobile/mock-homepage.html b/mobile/mock-homepage.html index b541dbb0a95..fe8301fd9bd 100644 --- a/mobile/mock-homepage.html +++ b/mobile/mock-homepage.html @@ -1,999 +1,1374 @@ - + - - - -Orca Mobile – Homepage Redesign - - - - -

- - - -
- - -
-
-
- - Orca -
-
- -
-
Welcome back
-
- -
-
- -
294
-
Agents
-
-
- -
1d 14h
-
Agent time
-
-
- -
277
-
PRs
-
-
- -
Desktops
-
-
-
- -
-
-
-
-
Host 1
-
-
Connected
-
-
- -
+ .empty-steps { + width: 100%; + padding: 0 24px 40px; + display: flex; + flex-direction: column; + gap: 0; + } + .empty-step { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px 0; + } + .empty-step + .empty-step { + border-top: 1px solid var(--border-subtle); + } + .step-num { + width: 28px; + height: 28px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: var(--text-secondary); + flex-shrink: 0; + margin-top: 1px; + } + .step-text { + flex: 1; + } + .step-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 3px; + } + .step-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; + } + + + +
+ + +
-
-
- -
-
-
Pair another desktop
-
Scan a QR code from Orca desktop
-
-
-
- -
- -
- - Settings -
-
- - -
-
-
- - Orca -
-
- - -
-
- -
- -
Welcome back
-
- - -
-
-
- -
-
294
-
Agents
-
-
-
- -
-
1d 14h
-
Agent time
-
-
-
- -
-
277
-
PRs
-
-
- -
Tasks
-
-
-
- -
-
-
Task inbox
-
- - - - - - GitHub, GitLab, Linear + +
+
+
+ + + + Orca
-
7
-
- -
-
-
-
-
3
-
Assigned
-
-
-
2
-
Review
-
-
-
2
-
Drafts
-
-
-
-
Best candidate: puts issues/PRs at the same hierarchy as desktops without overloading Quick Actions.
- -
Desktops
-
-
-
- -
+
+
Welcome back
-
-
Host 1
-
- 12 worktrees -
- 3 active + +
+
+ + + + +
294
+
Agents
+
+
+ + + + +
1d 14h
+
Agent time
+
+
+ + + + + + +
277
+
PRs
-
-
- - 7 + +
Desktops
+
+
+
+ + + + + +
+
+
+
+
Host 1
+
+
Connected
+
+
+ + + + + +
-
- + +
+
+ + + + + + +
+
+
Pair another desktop
+
+ Scan a QR code from Orca desktop +
+
-
-
-
- -
-
-
-
Work Laptop
-
- Disconnected -
- Last seen 2h ago -
-
-
- -
-
-
+
- -
Resume
-
-
- - -
-
-
fix-auth-middleware
-
- - orca  ·  feat/auth-v2 -
-
-
- -
-
- - -
Quick Actions
- - - -
Recent Activity
-
-
-
-
-
-
Assigned: Investigate remote task loading
-
GitHub · Host 1 · 8 min ago
-
-
-
-
-
-
Agent completed: fix login validation
-
Host 1 · 12 min ago
-
-
-
-
-
-
PR #284 merged: update auth middleware
-
Host 1 · 1h ago
-
-
-
-
-
-
Agent started: refactor payment flow
-
Host 1 · 2h ago
-
-
-
-
- -
-
- - -
-
-
- - Orca -
-
- -
-
- -
- -
Welcome to Orca
-
- -
-
-
- - - - - +
+ + + + Settings
-
Connect your desktop
-
- Pair with Orca on your computer to monitor worktrees, watch agents work, and manage terminals — all from your phone. -
-
-
-
How it works
-
-
1
-
-
Open Orca desktop
-
Go to Settings → Mobile and generate a pairing QR code.
+ +
+
+
+ + + + Orca +
+
+ +
-
-
2
-
-
Scan the code
-
Tap the button above to open the scanner. Point at the QR code on your screen.
+ +
+
Welcome back
+
+ + +
+
+
+ + + + +
+
294
+
Agents
+
+
+
+ + + + +
+
1d 14h
+
Agent time
+
+
+
+ + + + + + +
+
277
+
PRs
-
-
3
-
-
You're connected
-
Your desktop will appear here. Everything is encrypted end-to-end.
+ +
Tasks
+
+
+
+ + + + + + +
+
+
Task inbox
+
+ + + + + + GitHub, GitLab, Linear +
+
+
7
+
+ + + +
+
+
+
+
3
+
Assigned
+
+
+
2
+
Review
+
+
+
2
+
Drafts
+
+
+
+
+ Best candidate: puts issues/PRs at the same hierarchy as desktops without overloading Quick + Actions. +
+ + +
Desktops
+
+
+
+ + + + + +
+
+
+
Host 1
+
+ 12 worktrees +
+ 3 active +
+
+
+
+ + + + + + + 7 +
+
+ + + +
+
+
+ +
+
+ + + + + +
+
+
+
Work Laptop
+
+ Disconnected +
+ Last seen 2h ago +
+
+
+ + + +
+
+
+ + +
Resume
+
+
+ + + + + +
+
+
fix-auth-middleware
+
+ + orca  ·  feat/auth-v2 +
+
+
+ + + +
+
+ + +
Quick Actions
+ + + +
Recent Activity
+
+
+
+
+
+
Assigned: Investigate remote task loading
+
GitHub · Host 1 · 8 min ago
+
+
+
+
+
+
Agent completed: fix login validation
+
Host 1 · 12 min ago
+
+
+
+
+
+
PR #284 merged: update auth middleware
+
Host 1 · 1h ago
+
+
+
+
+
+
Agent started: refactor payment flow
+
Host 1 · 2h ago
+
+
+
+
+ +
+
+ + +
+
+
+ + + + Orca +
+
+ +
+
+ +
+
Welcome to Orca
+
+ +
+
+
+ + + + + + +
+
Connect your desktop
+
+ Pair with Orca on your computer to monitor worktrees, watch agents work, and manage + terminals — all from your phone. +
+ +
+ +
+
How it works
+
+
1
+
+
Open Orca desktop
+
Go to Settings → Mobile and generate a pairing QR code.
+
+
+
+
2
+
+
Scan the code
+
+ Tap the button above to open the scanner. Point at the QR code on your screen. +
+
+
+
+
3
+
+
You're connected
+
+ Your desktop will appear here. Everything is encrypted end-to-end. +
+
+
-
-
- - + + diff --git a/mobile/mock-tasks.html b/mobile/mock-tasks.html index 73ae68f3c4b..86eaa16ff5b 100644 --- a/mobile/mock-tasks.html +++ b/mobile/mock-tasks.html @@ -1,1030 +1,1420 @@ - + - - - -Orca Mobile - Tasks Mock - - - -
-
-
-
-
- -
- -

Tasks

+ + + + Orca Mobile - Tasks Mock + + + +
+
+
+
+
+ +
+ +

Tasks

+
+ +
- - + +
+ + +
+ + +
+ +
+

-
- - +
+
No GitHub tasks
+
+
+ +
- -
-

-
-
No GitHub tasks
-
-
+
+
+
- + - + function simulateLoad() { + state.loading = true + render() + window.setTimeout(() => { + state.loading = false + render() + }, 240) + } + + function showToast(message) { + toast.textContent = message + toast.classList.add('visible') + window.clearTimeout(showToast.timer) + showToast.timer = window.setTimeout(() => toast.classList.remove('visible'), 1500) + } + + function mutateSelectedStatus() { + const item = state.selectedItem + if (!item) return + if (state.provider === 'linear') return + if (item.state === 'closed') { + item.state = state.provider === 'gitlab' ? 'opened' : 'open' + item.status = 'Open' + } else { + item.state = 'closed' + item.status = 'Closed' + } + closeDrawer() + render() + showToast('Status updated') + } + + function createIssueFromForm() { + const title = state.createTitle.trim() + if (!title) return + const target = + state.provider === 'linear' + ? teams.find((team) => team.id === state.createTargetId) || teams[0] + : repos.find((repo) => repo.id === state.createTargetId) || repos[0] + const newItem = + state.provider === 'linear' + ? { + key: `lin-${Date.now()}`, + identifier: 'MOB-500', + title, + team: target.name, + status: 'Todo', + state: 'todo', + updatedAt: 'now', + labels: [] + } + : { + key: `${state.provider}-${Date.now()}`, + type: 'issue', + number: state.provider === 'github' ? 1291 : 75, + title, + repoId: target.id, + repoName: target.displayName, + status: 'Open', + state: state.provider === 'github' ? 'open' : 'opened', + updatedAt: 'now', + labels: [] + } + tasks[state.provider].unshift(newItem) + state.createTitle = '' + state.createBody = '' + closeDrawer() + render() + showToast('Issue created') + } + + function findCurrentItem(key) { + if (state.provider === 'gitlab' && state.gitlabView === 'todos') + return tasks.gitlabTodos.find((item) => item.key === key) + return tasks[state.provider].find((item) => item.key === key) + } + + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, '`') + } + + document.addEventListener('click', (event) => { + const trigger = event.target.closest('[data-action]') + if (!trigger) return + const action = trigger.dataset.action + + if (action === 'open-provider') openDrawer('provider') + if (action === 'open-github-kind') openDrawer('github-kind') + if (action === 'open-github-preset') openDrawer('github-preset') + if (action === 'open-gitlab-view') openDrawer('gitlab-view') + if (action === 'open-gitlab-filter') openDrawer('gitlab-filter') + if (action === 'open-linear-filter') openDrawer('linear-filter') + if (action === 'close-drawer') closeDrawer() + if (action === 'open-create') { + state.createTitle = '' + state.createBody = '' + state.createTargetId = state.provider === 'linear' ? teams[0].id : repos[0].id + openDrawer('create') + } + if (action === 'open-create-target') openDrawer('create-target') + if (action === 'clear-search') { + state.query = '' + searchInput.value = '' + render() + } + if (action === 'refresh') { + simulateLoad() + showToast('Tasks refreshed') + } + if (action === 'toast') showToast(trigger.dataset.message || 'Action') + if (action === 'open-action') { + const item = findCurrentItem(trigger.dataset.key) + if (state.provider === 'gitlab' && state.gitlabView === 'todos') { + showToast('Would open GitLab todo URL') + return + } + if (item) openDrawer('action', item) + } + if (action === 'select-provider') setProvider(trigger.dataset.value) + if (action === 'select-github-kind') { + state.githubKind = trigger.dataset.value + const preset = state.githubKind === 'prs' ? prPresets[0] : issuePresets[0] + state.githubPreset = preset.value + state.query = preset.query + searchInput.value = state.query + closeDrawer() + render() + } + if (action === 'select-github-preset') { + const preset = [...issuePresets, ...prPresets].find( + (entry) => entry.value === trigger.dataset.value + ) + if (preset) { + state.githubPreset = preset.value + state.githubKind = + preset.value === 'issues' || preset.value === 'my-issues' ? 'issues' : 'prs' + state.query = preset.query + searchInput.value = state.query + } + closeDrawer() + render() + } + if (action === 'select-gitlab-view') { + state.gitlabView = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-gitlab-filter') { + state.gitlabFilter = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-linear-filter') { + state.linearFilter = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-create-target') { + state.createTargetId = trigger.dataset.value + openDrawer('create') + } + if (action === 'select-workspace-target') { + closeDrawer() + showToast('Workspace created') + } + if (action === 'create-workspace') { + if (state.provider === 'linear' && repos.filter((repo) => !repo.unsupported).length > 1) + openDrawer('workspace-target', state.selectedItem) + else { + closeDrawer() + showToast('Workspace created') + } + } + if (action === 'open-browser') showToast('Would open task URL') + if (action === 'toggle-status') mutateSelectedStatus() + if (action === 'set-linear-status') { + const selected = linearStates.find((entry) => entry.id === trigger.dataset.value) + if (state.selectedItem && selected) { + state.selectedItem.state = selected.id + state.selectedItem.status = selected.name + } + closeDrawer() + render() + showToast('Linear status updated') + } + if (action === 'create-issue') createIssueFromForm() + }) + + document.addEventListener('input', (event) => { + if (event.target === searchInput) { + state.query = searchInput.value + render() + return + } + if (event.target.id === 'createTitle') { + state.createTitle = event.target.value + renderDrawer() + const input = document.getElementById('createTitle') + input.focus() + input.setSelectionRange(input.value.length, input.value.length) + } + if (event.target.id === 'createBody') { + state.createBody = event.target.value + } + }) + + render() + + diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index cfaed3061b9..b53ef24d612 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -1,5 +1,13 @@ import { Buffer } from 'buffer' -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode +} from 'react' import { ActivityIndicator, AppState, @@ -209,21 +217,14 @@ export function MobileBrowserPane({ } }, [addressFocused, tab.url]) - useEffect(() => { + useLayoutEffect(() => { + // Why: gesture and stream handlers need committed values before passive + // Effects flush, without leaking refs from an uncommitted render. frameMetadataRef.current = frameMetadata - }, [frameMetadata]) - - useEffect(() => { layoutRef.current = layout - }, [layout]) - - useEffect(() => { dialogRef.current = dialog - }, [dialog]) - - useEffect(() => { zoomRef.current = zoom - }, [zoom]) + }, [dialog, frameMetadata, layout, zoom]) useEffect(() => { lastZoomResetUrlRef.current = tab.url || 'about:blank' diff --git a/mobile/src/browser/mobile-browser-pane-source.test.ts b/mobile/src/browser/mobile-browser-pane-source.test.ts new file mode 100644 index 00000000000..69cd5af5391 --- /dev/null +++ b/mobile/src/browser/mobile-browser-pane-source.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('./MobileBrowserPane.tsx', import.meta.url), 'utf8') + +function sliceBetween(startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('MobileBrowserPane source invariants', () => { + it('mirrors handler refs in a layout effect instead of during render', () => { + const mirrorBlock = sliceBetween( + 'useLayoutEffect(() => {', + ' useEffect(() => {\n lastZoomResetUrlRef.current' + ) + + expect(mirrorBlock).toContain('frameMetadataRef.current = frameMetadata') + expect(mirrorBlock).toContain('layoutRef.current = layout') + expect(mirrorBlock).toContain('dialogRef.current = dialog') + expect(mirrorBlock).toContain('zoomRef.current = zoom') + expect(mirrorBlock).toContain('}, [dialog, frameMetadata, layout, zoom])') + }) +}) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 88b6e0e7293..e96f035c2a7 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -22,6 +22,7 @@ import Animated, { Extrapolation } from 'react-native-reanimated' import { colors, spacing } from '../theme/mobile-theme' +import { useResponsiveLayout } from '../layout/responsive-layout' const DISMISS_THRESHOLD = 80 const SPRING_CONFIG = { damping: 28, stiffness: 400 } @@ -93,6 +94,10 @@ function MountedBottomDrawer({ const contentDragCanDismiss = useSharedValue(false) const { height: screenHeight } = useWindowDimensions() const insets = useSafeAreaInsets() + // Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and + // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard + // transforms below) is unchanged, so phone behavior stays identical. + const { isWideLayout, modalMaxWidth } = useResponsiveLayout() useEffect(() => { if (visible) { @@ -258,11 +263,13 @@ function MountedBottomDrawer({ - + void onKeysChanged: (keys: CustomKey[]) => void + onManageShortcuts?: () => void } export async function loadCustomKeys(): Promise { @@ -76,7 +77,7 @@ export async function saveCustomKeys(keys: CustomKey[]): Promise { await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys)) } -export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { +export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) { const [step, setStep] = useState('choose-type') const [shortcutKey, setShortcutKey] = useState('c') const [shortcutModifiers, setShortcutModifiers] = useState(['ctrl']) @@ -216,6 +217,18 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { Text Macro Send custom text command + {onManageShortcuts ? ( + <> + + [styles.row, pressed && styles.rowPressed]} + onPress={onManageShortcuts} + > + Manage Shortcuts + Show or hide default shortcut keys + + + ) : null} )} diff --git a/mobile/src/layout/responsive-layout-metrics.test.ts b/mobile/src/layout/responsive-layout-metrics.test.ts new file mode 100644 index 00000000000..263344d7502 --- /dev/null +++ b/mobile/src/layout/responsive-layout-metrics.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { getResponsiveLayoutMetrics } from './responsive-layout-metrics' +import { spacing } from '../theme/mobile-theme' + +describe('responsive layout metrics', () => { + it('uses capped tablet layout for iPad portrait and landscape windows', () => { + expect(getResponsiveLayoutMetrics(820, 1180)).toMatchObject({ + isLandscape: false, + isTabletLayout: true, + isWideLayout: true, + contentMaxWidth: 720, + modalMaxWidth: 480, + horizontalPadding: spacing.xl + }) + + expect(getResponsiveLayoutMetrics(1180, 820)).toMatchObject({ + isLandscape: true, + isTabletLayout: true, + isWideLayout: true + }) + }) + + it('keeps narrow iPad split windows phone-like', () => { + expect(getResponsiveLayoutMetrics(560, 1024)).toMatchObject({ + isTabletLayout: false, + isWideLayout: false, + horizontalPadding: spacing.lg + }) + }) + + it('keeps landscape phones out of wide tablet layout', () => { + expect(getResponsiveLayoutMetrics(932, 430)).toMatchObject({ + isLandscape: true, + isTabletLayout: false, + isWideLayout: false, + horizontalPadding: spacing.lg + }) + }) +}) diff --git a/mobile/src/layout/responsive-layout-metrics.ts b/mobile/src/layout/responsive-layout-metrics.ts new file mode 100644 index 00000000000..4fffbcea989 --- /dev/null +++ b/mobile/src/layout/responsive-layout-metrics.ts @@ -0,0 +1,44 @@ +import { spacing } from '../theme/mobile-theme' + +// Use actual window size so narrow iPad splits keep phone-like layouts. +const WIDE_LAYOUT_MIN_WIDTH = 700 + +// Why: width alone catches landscape phones; capped tablet layouts need room +// in both dimensions so phone rotation does not switch UI classes. +const TABLET_LAYOUT_MIN_SHORT_SIDE = 600 + +const CONTENT_MAX_WIDTH = 720 +const MODAL_MAX_WIDTH = 480 + +export type ResponsiveLayoutMetrics = { + width: number + height: number + isLandscape: boolean + /** Window is wide enough to cap and center primary content. */ + isWideLayout: boolean + /** Tablet-class canvas (both dimensions large); false in narrow splits. */ + isTabletLayout: boolean + /** Max width for primary scrollable content on wide layouts. */ + contentMaxWidth: number + /** Max width for centered sheets/dialogs on wide layouts. */ + modalMaxWidth: number + /** Recommended horizontal gutter for the current width. */ + horizontalPadding: number +} + +export function getResponsiveLayoutMetrics(width: number, height: number): ResponsiveLayoutMetrics { + const isTabletLayout = Math.min(width, height) >= TABLET_LAYOUT_MIN_SHORT_SIDE + const isWideLayout = width >= WIDE_LAYOUT_MIN_WIDTH && isTabletLayout + + return { + width, + height, + isLandscape: width > height, + isWideLayout, + isTabletLayout, + contentMaxWidth: CONTENT_MAX_WIDTH, + modalMaxWidth: MODAL_MAX_WIDTH, + // Roomier gutters once content is capped so it isn't glued to the edges. + horizontalPadding: isWideLayout ? spacing.xl : spacing.lg + } +} diff --git a/mobile/src/layout/responsive-layout.ts b/mobile/src/layout/responsive-layout.ts new file mode 100644 index 00000000000..9a93afbaa42 --- /dev/null +++ b/mobile/src/layout/responsive-layout.ts @@ -0,0 +1,12 @@ +import { useWindowDimensions } from 'react-native' +import { + getResponsiveLayoutMetrics, + type ResponsiveLayoutMetrics +} from './responsive-layout-metrics' + +export type ResponsiveLayout = ResponsiveLayoutMetrics + +export function useResponsiveLayout(): ResponsiveLayout { + const { width, height } = useWindowDimensions() + return getResponsiveLayoutMetrics(width, height) +} diff --git a/mobile/src/session/mobile-new-tab-agent-options.test.ts b/mobile/src/session/mobile-new-tab-agent-options.test.ts new file mode 100644 index 00000000000..5de44caa942 --- /dev/null +++ b/mobile/src/session/mobile-new-tab-agent-options.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { + buildMobileNewTabAgentOptions, + orderMobileNewTabAgents +} from './mobile-new-tab-agent-options' + +describe('mobile new-tab agent options', () => { + it('orders the enabled detected default first', () => { + expect(orderMobileNewTabAgents('codex', ['gemini', 'codex', 'claude'], ['gemini'])).toEqual([ + 'codex', + 'claude' + ]) + }) + + it('returns labeled options for enabled detected agents only', () => { + expect( + buildMobileNewTabAgentOptions({ defaultTuiAgent: null, disabledTuiAgents: ['claude'] }, [ + 'claude', + 'codex', + 'not-real' + ]) + ).toEqual([{ agent: 'codex', label: 'Codex' }]) + }) + + it('does not show stale presets while detection is pending', () => { + expect(buildMobileNewTabAgentOptions({ defaultTuiAgent: 'codex' }, null)).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-new-tab-agent-options.ts b/mobile/src/session/mobile-new-tab-agent-options.ts new file mode 100644 index 00000000000..51de5b610f2 --- /dev/null +++ b/mobile/src/session/mobile-new-tab-agent-options.ts @@ -0,0 +1,51 @@ +import type { TuiAgent } from '../../../src/shared/types' +import { + filterEnabledMobileTuiAgents, + isMobileTuiAgent, + MOBILE_TUI_AGENT_AUTO_PICK_ORDER, + MOBILE_TUI_AGENT_LABELS +} from '../tasks/mobile-tui-agents' + +export type MobileNewTabAgentSettings = { + defaultTuiAgent?: TuiAgent | 'blank' | null + disabledTuiAgents?: unknown +} + +export type MobileNewTabAgentOption = { + agent: TuiAgent + label: string +} + +export function orderMobileNewTabAgents( + defaultAgent: TuiAgent | 'blank' | null | undefined, + detectedAgents: Iterable, + disabledAgents?: unknown +): TuiAgent[] { + const detected = new Set([...detectedAgents].filter(isMobileTuiAgent)) + const enabledDetected = filterEnabledMobileTuiAgents( + MOBILE_TUI_AGENT_AUTO_PICK_ORDER, + disabledAgents + ).filter((agent) => detected.has(agent)) + + if (defaultAgent && defaultAgent !== 'blank' && enabledDetected.includes(defaultAgent)) { + return [defaultAgent, ...enabledDetected.filter((agent) => agent !== defaultAgent)] + } + return enabledDetected +} + +export function buildMobileNewTabAgentOptions( + settings: MobileNewTabAgentSettings | null | undefined, + detectedAgentIds: Iterable | null +): MobileNewTabAgentOption[] { + if (!detectedAgentIds) { + return [] + } + return orderMobileNewTabAgents( + settings?.defaultTuiAgent, + detectedAgentIds, + settings?.disabledTuiAgents + ).map((agent) => ({ + agent, + label: MOBILE_TUI_AGENT_LABELS[agent] + })) +} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 97de19e656a..99771f3e453 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -882,6 +882,8 @@ const XTERM_HTML = ` var longPressOrigin = null; // {x,y, identifier} var edgeScrollTimer = null; var edgeScrollDir = 0; + var edgeScrollClientX = 0; + var edgeScrollClientY = 0; // Eviction watchdog: linesEverWritten counts onLineFeed since last init. // Once buffer is full, every onLineFeed evicts the top row in xterm and @@ -1074,11 +1076,15 @@ const XTERM_HTML = ` if (!cell) return ''; var eventCode = lines < 0 ? 64 : 65; if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'; } if (sgrMouseMode) { // Why: xterm increments zero-based mouse cells before encoding reports. - return ESC + '[<' + eventCode + ';' + (cell.col + 1) + ';' + (cell.row + 1) + 'M'; + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M'; } // Why: xterm increments zero-based mouse cells before encoding reports. var button = eventCode + 32; @@ -1090,6 +1096,47 @@ const XTERM_HTML = ` return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); } + function isSafeSgrMouseCoordinate(value) { + return Number.isInteger(value) && value >= 0 && value <= 9999; + } + + function buildMouseClickInput(clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (!isClickMouseTrackingMode(mouseTrackingMode)) return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + if (sgrMousePixelsMode) { + // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions. + var pixelX = cell.x; + var pixelY = cell.y; + if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return ''; + var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M'; + if (mouseTrackingMode === 'x10') return pixelPress; + return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm'; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; + if (mouseTrackingMode === 'x10') return sgrPress; + return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + if (col > 126 || row > 126) return ''; + var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); + if (mouseTrackingMode === 'x10') return press; + return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isClickMouseTrackingMode(mode) { + return mode !== 'none'; + } + function isWheelMouseTrackingMode(mode) { return mode !== 'none' && mode !== 'x10'; } @@ -1370,10 +1417,30 @@ const XTERM_HTML = ` selMenu.style.left = clampedLeft + 'px'; } + function syncSelectionHandleToViewportPoint(handle, clientX, clientY) { + var c = viewportToCell(clientX, clientY); + if (!c || !sel) return false; + if (handle === 'start') sel.anchor = c; + else sel.focus = c; + applyXtermSelection(); + return true; + } + + function syncEdgeScrollSelectionEndpoint() { + if (!sel || !sel.activeHandle) return false; + // Why: WebView may not emit new touchmove events while a handle is held + // at the edge; resample the stored finger point after each viewport scroll. + return syncSelectionHandleToViewportPoint( + sel.activeHandle, + edgeScrollClientX, + edgeScrollClientY + ); + } + function startEdgeScroll(dir) { if (edgeScrollDir === dir) return; - edgeScrollDir = dir; stopEdgeScroll(); + edgeScrollDir = dir; edgeScrollTimer = setInterval(function() { if (!term || edgeScrollDir === 0) return; var beforeY = term.buffer.active.viewportY; @@ -1384,6 +1451,7 @@ const XTERM_HTML = ` stopEdgeScroll(); return; } + syncEdgeScrollSelectionEndpoint(); repositionOverlay(); }, EDGE_SCROLL_INTERVAL); } @@ -1397,11 +1465,9 @@ const XTERM_HTML = ` } function handleDragMove(handle, clientX, clientY) { - var c = viewportToCell(clientX, clientY); - if (!c || !sel) return; - if (handle === 'start') sel.anchor = c; - else sel.focus = c; - applyXtermSelection(); + edgeScrollClientX = clientX; + edgeScrollClientY = clientY; + if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return; repositionOverlay(); if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1); else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1); @@ -1540,7 +1606,12 @@ const XTERM_HTML = ` } if (dispatch.mode === 'surface') { if (e.touches.length === 0 && longPressOrigin && selMode !== 'select') { - notify({ type: 'terminal-tap' }); + var clickInput = buildMouseClickInput(longPressOrigin.x, longPressOrigin.y); + if (clickInput) { + notify({ type: 'terminal-input', bytes: clickInput }); + } else if (!isClickMouseTrackingMode(getMouseTrackingMode())) { + notify({ type: 'terminal-tap' }); + } } clearLongPress(); if (e.touches.length === 0) { diff --git a/mobile/src/terminal/terminal-accessory-keys.test.ts b/mobile/src/terminal/terminal-accessory-keys.test.ts index 650f45dc4e1..13a91824819 100644 --- a/mobile/src/terminal/terminal-accessory-keys.test.ts +++ b/mobile/src/terminal/terminal-accessory-keys.test.ts @@ -4,20 +4,44 @@ import { buildTerminalShortcutKey, TERMINAL_ACCESSORY_KEYS } from './terminal-ac describe('TERMINAL_ACCESSORY_KEYS', () => { it('sends reverse-tab with a non-repeatable Shift+Tab key', () => { - const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.label === 'Shift+Tab') + const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'shiftTab') expect(key).toEqual({ + id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }) }) + it('includes a non-repeatable Enter default key', () => { + expect(TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'enter')).toEqual({ + id: 'enter', + label: 'Enter', + bytes: '\r', + accessibilityLabel: 'Enter' + }) + }) + + it('has unique non-empty built-in ids', () => { + const ids = TERMINAL_ACCESSORY_KEYS.map((key) => key.id) + + expect(ids.every((id) => id.length > 0)).toBe(true) + expect(new Set(ids).size).toBe(ids.length) + }) + it('keeps repeat behavior explicit for built-in terminal keys', () => { - const repeatableLabels = new Set(['⌫', 'Del', '↑', '↓', '←', '→']) + const repeatableIds = new Set([ + 'backspace', + 'delete', + 'arrowUp', + 'arrowDown', + 'arrowLeft', + 'arrowRight' + ]) for (const key of TERMINAL_ACCESSORY_KEYS) { - expect(key.repeatable === true).toBe(repeatableLabels.has(key.label)) + expect(key.repeatable === true).toBe(repeatableIds.has(key.id)) } }) @@ -45,6 +69,11 @@ describe('TERMINAL_ACCESSORY_KEYS', () => { bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }) + expect(buildTerminalShortcutKey({ key: 'enter', modifiers: [] })).toEqual({ + label: 'Enter', + bytes: '\r', + accessibilityLabel: 'Enter' + }) expect(buildTerminalShortcutKey({ key: 'arrowRight', modifiers: ['ctrl', 'shift'] })).toEqual({ label: 'Ctrl+Shift+→', bytes: '\x1b[1;6C', diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index 82ef9d83fc9..2923c25431b 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -1,4 +1,5 @@ export type TerminalAccessoryKey = { + id: string label: string bytes: string accessibilityLabel?: string @@ -199,25 +200,50 @@ export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [ })) export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ - { label: 'Esc', bytes: '\x1b' }, - { label: 'Tab', bytes: '\t' }, + { id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' }, + { id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' }, + { id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' }, // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. - { label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, - { label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, - { label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true }, - { label: '↑', bytes: '\x1b[A', repeatable: true }, - { label: '↓', bytes: '\x1b[B', repeatable: true }, - { label: '←', bytes: '\x1b[D', repeatable: true }, - { label: '→', bytes: '\x1b[C', repeatable: true }, - { label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, - { label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, - { label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, - { label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, - { label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, - { label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, - { label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, - { label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, - { label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } + { id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, + { id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, + { + id: 'delete', + label: 'Del', + bytes: '\x1b[3~', + accessibilityLabel: 'Forward delete', + repeatable: true + }, + { id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true }, + { + id: 'arrowDown', + label: '↓', + bytes: '\x1b[B', + accessibilityLabel: 'Arrow Down', + repeatable: true + }, + { + id: 'arrowLeft', + label: '←', + bytes: '\x1b[D', + accessibilityLabel: 'Arrow Left', + repeatable: true + }, + { + id: 'arrowRight', + label: '→', + bytes: '\x1b[C', + accessibilityLabel: 'Arrow Right', + repeatable: true + }, + { id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, + { id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, + { id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, + { id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, + { id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, + { id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, + { id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, + { id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, + { id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } ] export function buildTerminalShortcutKey( diff --git a/mobile/src/terminal/terminal-accessory-layout.test.ts b/mobile/src/terminal/terminal-accessory-layout.test.ts new file mode 100644 index 00000000000..57e1d7f0343 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-layout.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, + createTerminalAccessoryLayoutPreference, + getDefaultTerminalAccessoryBuiltInIds, + getVisibleTerminalAccessoryKeys, + loadTerminalAccessoryLayout, + normalizeTerminalAccessoryLayoutPreference, + resetTerminalAccessoryBuiltInIds, + saveTerminalAccessoryLayout, + setTerminalAccessoryBuiltInVisible +} from './terminal-accessory-layout' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: asyncStorageMock +})) + +describe('terminal accessory layout', () => { + beforeEach(() => { + asyncStorageMock.getItem.mockReset() + asyncStorageMock.setItem.mockReset() + }) + + it('defaults include enter', () => { + expect(getDefaultTerminalAccessoryBuiltInIds()).toContain('enter') + expect(getVisibleTerminalAccessoryKeys(getDefaultTerminalAccessoryBuiltInIds())).toContainEqual( + expect.objectContaining({ id: 'enter', bytes: '\r' }) + ) + }) + + it('normalizes invalid storage to defaults', () => { + expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual( + getDefaultTerminalAccessoryBuiltInIds() + ) + expect( + normalizeTerminalAccessoryLayoutPreference({ + version: 1, + visibleBuiltInIds: ['escape'] + }).visibleBuiltInIds + ).toEqual(getDefaultTerminalAccessoryBuiltInIds()) + }) + + it('returns defaults for corrupt or unreadable storage', async () => { + asyncStorageMock.getItem.mockResolvedValueOnce('{') + await expect(loadTerminalAccessoryLayout()).resolves.toEqual( + createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds()) + ) + + asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable')) + await expect(loadTerminalAccessoryLayout()).resolves.toEqual( + createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds()) + ) + }) + + it('ignores removed ids and de-dupes visible ids', () => { + expect( + normalizeTerminalAccessoryLayoutPreference({ + version: 1, + visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'], + knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds() + }).visibleBuiltInIds + ).toEqual(['escape', 'tab']) + }) + + it('appends new defaults only when absent from known ids', () => { + const current = ['escape', 'tab', 'enter'] + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: ['escape'], + knownBuiltInIds: ['escape', 'tab'] + }, + current + ).visibleBuiltInIds + ).toEqual(['escape', 'enter']) + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: ['escape'], + knownBuiltInIds: current + }, + current + ).visibleBuiltInIds + ).toEqual(['escape']) + }) + + it('keeps hidden known defaults hidden, including an all-hidden layout', () => { + const current = ['escape', 'tab', 'enter'] + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: [], + knownBuiltInIds: current + }, + current + ).visibleBuiltInIds + ).toEqual([]) + }) + + it('toggle and reset helpers preserve built-in order', () => { + expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([ + 'escape', + 'tab' + ]) + expect( + setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab']) + ).toEqual(['tab']) + expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds()) + }) + + it('saves visible ids with current known built-in ids', async () => { + asyncStorageMock.setItem.mockResolvedValueOnce(undefined) + + await saveTerminalAccessoryLayout(['tab', 'tab', 'missing']) + + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, + JSON.stringify(createTerminalAccessoryLayoutPreference(['tab'])) + ) + }) + + it('rejects write failures without mutating helper output', async () => { + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('nope')) + + await expect(saveTerminalAccessoryLayout(['escape'])).rejects.toThrow('nope') + expect(createTerminalAccessoryLayoutPreference(['escape']).visibleBuiltInIds).toEqual([ + 'escape' + ]) + }) +}) diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts new file mode 100644 index 00000000000..2584e12fe3e --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-layout.ts @@ -0,0 +1,137 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys' + +export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout' + +export type TerminalAccessoryLayoutPreference = { + version: 1 + visibleBuiltInIds: string[] + knownBuiltInIds: string[] +} + +function builtInIds(): string[] { + return TERMINAL_ACCESSORY_KEYS.map((key) => key.id) +} + +function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference { + return { + version: 1, + visibleBuiltInIds: [...ids], + knownBuiltInIds: [...ids] + } +} + +function stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null + return value.every((item): item is string => typeof item === 'string') ? value : null +} + +function dedupeKnownIds(ids: string[], builtInSet: Set): string[] { + const seen = new Set() + const out: string[] = [] + for (const id of ids) { + if (!builtInSet.has(id) || seen.has(id)) continue + seen.add(id) + out.push(id) + } + return out +} + +export function getDefaultTerminalAccessoryBuiltInIds(): string[] { + return builtInIds() +} + +export function normalizeTerminalAccessoryLayoutPreference( + value: unknown, + currentBuiltInIds = builtInIds() +): TerminalAccessoryLayoutPreference { + const fallback = defaultPreference(currentBuiltInIds) + if (!value || typeof value !== 'object') return fallback + + const candidate = value as { + version?: unknown + visibleBuiltInIds?: unknown + knownBuiltInIds?: unknown + } + const visibleInput = stringArray(candidate.visibleBuiltInIds) + const knownInput = stringArray(candidate.knownBuiltInIds) + if (candidate.version !== 1 || !visibleInput || !knownInput) return fallback + + const builtInSet = new Set(currentBuiltInIds) + const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id))) + const visibleBuiltInIds = dedupeKnownIds(visibleInput, builtInSet) + + for (const id of currentBuiltInIds) { + if (!knownInputSet.has(id) && !visibleBuiltInIds.includes(id)) { + visibleBuiltInIds.push(id) + } + } + + return { + version: 1, + visibleBuiltInIds, + knownBuiltInIds: [...currentBuiltInIds] + } +} + +export function createTerminalAccessoryLayoutPreference( + visibleBuiltInIds: string[], + currentBuiltInIds = builtInIds() +): TerminalAccessoryLayoutPreference { + return { + version: 1, + visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)), + knownBuiltInIds: [...currentBuiltInIds] + } +} + +export function setTerminalAccessoryBuiltInVisible( + visibleBuiltInIds: string[], + id: string, + visible: boolean, + currentBuiltInIds = builtInIds() +): string[] { + const builtInSet = new Set(currentBuiltInIds) + if (!builtInSet.has(id)) { + return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds) + .visibleBuiltInIds + } + + const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet)) + if (visible) { + selected.add(id) + } else { + selected.delete(id) + } + return currentBuiltInIds.filter((builtInId) => selected.has(builtInId)) +} + +export function resetTerminalAccessoryBuiltInIds(): string[] { + return builtInIds() +} + +export function getVisibleTerminalAccessoryKeys( + visibleBuiltInIds: string[] +): TerminalAccessoryKey[] { + const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key])) + return dedupeKnownIds(visibleBuiltInIds, new Set(byId.keys())).flatMap((id) => { + const key = byId.get(id) + return key ? [key] : [] + }) +} + +export async function loadTerminalAccessoryLayout(): Promise { + try { + const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY) + if (!raw) return defaultPreference() + return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw)) + } catch { + return defaultPreference() + } +} + +export async function saveTerminalAccessoryLayout(visibleBuiltInIds: string[]): Promise { + const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds) + await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference)) +} diff --git a/mobile/src/terminal/terminal-gesture-input.test.ts b/mobile/src/terminal/terminal-gesture-input.test.ts index 48ad19e10bd..a120d569db9 100644 --- a/mobile/src/terminal/terminal-gesture-input.test.ts +++ b/mobile/src/terminal/terminal-gesture-input.test.ts @@ -19,6 +19,14 @@ describe('isTerminalGestureInput', () => { expect(isTerminalGestureInput(`${ESC}[<64;0;0M`)).toBe(true) }) + it('accepts bounded SGR left-click press and release sequences', () => { + expect(isTerminalGestureInput(`${ESC}[<0;38;20M${ESC}[<0;38;20m`)).toBe(true) + expect(countTerminalGestureInputSequences(`${ESC}[<0;38;20M${ESC}[<0;38;20m`)).toBe(2) + expect(countTerminalGestureInputSequences(`${ESC}[<0;1;1M${ESC}[<0;1;1m`)).toBe(2) + expect(countTerminalGestureInputSequences(`${ESC}[<0;0;0M${ESC}[<0;0;0m`)).toBe(2) + expect(isTerminalGestureInput(`${ESC}[<0;9999;9999M${ESC}[<0;9999;9999m`)).toBe(true) + }) + it('accepts repeated default mouse wheel sequences', () => { expect( isTerminalGestureInput( @@ -27,6 +35,14 @@ describe('isTerminalGestureInput', () => { ).toBe(true) }) + it('accepts bounded default left-click press and release sequences', () => { + expect( + isTerminalGestureInput( + `${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(35, 33, 33)}` + ) + ).toBe(true) + }) + it('rejects shell text and other terminal input', () => { expect(isTerminalGestureInput('rm -rf .\r')).toBe(false) expect(isTerminalGestureInput(`${ESC}[200~paste${ESC}[201~`)).toBe(false) @@ -39,4 +55,12 @@ describe('isTerminalGestureInput', () => { expect(countTerminalGestureInputSequences(`${ESC}[A`.repeat(33))).toBeNull() expect(isTerminalGestureInput(`${ESC}[A`.repeat(700))).toBe(false) }) + + it('rejects malformed SGR click reports and wheel releases', () => { + expect(isTerminalGestureInput(`${ESC}[<0;;1M`)).toBe(false) + expect(isTerminalGestureInput(`${ESC}[<0;-1;1M`)).toBe(false) + expect(isTerminalGestureInput(`${ESC}[<0;10000;1M`)).toBe(false) + expect(isTerminalGestureInput(`${ESC}[<64;1;1m`)).toBe(false) + expect(isTerminalGestureInput(`${ESC}[<65;1;1m`)).toBe(false) + }) }) diff --git a/mobile/src/terminal/terminal-gesture-input.ts b/mobile/src/terminal/terminal-gesture-input.ts index 15b3a9d11df..83450a1730b 100644 --- a/mobile/src/terminal/terminal-gesture-input.ts +++ b/mobile/src/terminal/terminal-gesture-input.ts @@ -1,31 +1,51 @@ const ESC = '\x1b' const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048 const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32 -const SGR_MOUSE_WHEEL_SEQUENCE_RE = new RegExp(`^${ESC}\\[<(64|65);[0-9]{1,4};[0-9]{1,4}M$`) +const SGR_MOUSE_GESTURE_SEQUENCE_RE = new RegExp( + `^${ESC}\\[<(0|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` +) -function isDefaultMouseWheelSequence(bytes: string, offset: number): number | null { +function isDefaultMouseGestureSequence(bytes: string, offset: number): number | null { if (!bytes.startsWith(`${ESC}[M`, offset) || offset + 6 > bytes.length) { return null } const button = bytes.charCodeAt(offset + 3) const col = bytes.charCodeAt(offset + 4) const row = bytes.charCodeAt(offset + 5) - if ((button === 96 || button === 97) && col >= 33 && col <= 126 && row >= 33 && row <= 126) { + if ( + (button === 32 || button === 35 || button === 96 || button === 97) && + col >= 33 && + col <= 126 && + row >= 33 && + row <= 126 + ) { return offset + 6 } return null } -function isSgrMouseWheelSequence(bytes: string, offset: number): number | null { +function isSgrMouseGestureSequence(bytes: string, offset: number): number | null { if (!bytes.startsWith(`${ESC}[<`, offset)) { return null } - const end = bytes.indexOf('M', offset) + const pressEnd = bytes.indexOf('M', offset) + const releaseEnd = bytes.indexOf('m', offset) + const end = + pressEnd === -1 ? releaseEnd : releaseEnd === -1 ? pressEnd : Math.min(pressEnd, releaseEnd) if (end === -1) { return null } const sequence = bytes.slice(offset, end + 1) - return SGR_MOUSE_WHEEL_SEQUENCE_RE.test(sequence) ? end + 1 : null + const match = SGR_MOUSE_GESTURE_SEQUENCE_RE.exec(sequence) + if (!match) return null + const button = match[1] + const col = Number(match[2]) + const row = Number(match[3]) + const final = match[4] + if (button === '0') { + return col >= 0 && row >= 0 ? end + 1 : null + } + return final === 'M' ? end + 1 : null } function isArrowScrollSequence(bytes: string, offset: number): number | null { @@ -51,8 +71,8 @@ export function countTerminalGestureInputSequences(bytes: string): number | null while (offset < bytes.length) { const next = isArrowScrollSequence(bytes, offset) ?? - isSgrMouseWheelSequence(bytes, offset) ?? - isDefaultMouseWheelSequence(bytes, offset) + isSgrMouseGestureSequence(bytes, offset) ?? + isDefaultMouseGestureSequence(bytes, offset) if (next == null) { return null diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index bf5b7f7e27f..9aabc65a6d0 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -2,6 +2,10 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +const sessionSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) function sliceBetween(startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -107,4 +111,71 @@ describe('TerminalWebView scroll routing', () => { expect(source).toContain('var FRICTION = 0.972;') expect(source).toContain('var MIN_VEL = 0.012;') }) + + it('keeps selection edge autoscroll active and extends the dragged endpoint', () => { + const startBlock = sliceBetween('function startEdgeScroll(dir)', 'function stopEdgeScroll()') + expect(startBlock.indexOf('stopEdgeScroll();')).toBeLessThan( + startBlock.indexOf('edgeScrollDir = dir;') + ) + expect(startBlock.indexOf('term.scrollLines(edgeScrollDir);')).toBeLessThan( + startBlock.indexOf('syncEdgeScrollSelectionEndpoint();') + ) + + const dragMoveBlock = sliceBetween( + 'function handleDragMove(handle, clientX, clientY)', + ' // ============================================================\n // LATCHING TOUCH DISPATCHER' + ) + expect(dragMoveBlock).toContain('edgeScrollClientX = clientX;') + expect(dragMoveBlock).toContain('edgeScrollClientY = clientY;') + expect(dragMoveBlock).toContain('syncSelectionHandleToViewportPoint(handle, clientX, clientY)') + }) + + it('synthesizes bounded mouse clicks from surface taps before focus fallback', () => { + expect(source).toContain('function buildMouseClickInput(clientX, clientY)') + expect(source).toContain('function isClickMouseTrackingMode(mode)') + expect(source).toContain("return mode !== 'none';") + expect(source).toContain('var pixelX = cell.x;') + expect(source).toContain('var pixelY = cell.y;') + expect(source).toContain( + 'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return' + ) + expect(source).toContain( + 'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return' + ) + expect(source).toContain("if (mouseTrackingMode === 'x10') return pixelPress;") + expect(source).toContain("if (mouseTrackingMode === 'x10') return sgrPress;") + expect(source).toContain("if (mouseTrackingMode === 'x10') return press;") + expect(source).toContain("if (col > 126 || row > 126) return '';") + + const touchEndBlock = sliceBetween( + "document.addEventListener('touchend'", + '}, { capture: true, passive: true });' + ) + expect(touchEndBlock.indexOf('var clickInput = buildMouseClickInput')).toBeLessThan( + touchEndBlock.indexOf("notify({ type: 'terminal-tap' });") + ) + expect(touchEndBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });") + expect(touchEndBlock).toContain( + '} else if (!isClickMouseTrackingMode(getMouseTrackingMode())) {' + ) + }) + + it('allows x10 mouse gesture reports through the mobile session gate', () => { + expect(sessionSource).toContain('function isGestureMouseTrackingMode') + expect(sessionSource).toContain("return mode === 'x10' || isWheelMouseTrackingMode(mode)") + + const inputBlockStart = sessionSource.indexOf('const handleTerminalInput = useCallback') + expect(inputBlockStart).toBeGreaterThanOrEqual(0) + const inputBlockEnd = sessionSource.indexOf( + 'async function handleClearTerminal', + inputBlockStart + ) + expect(inputBlockEnd).toBeGreaterThan(inputBlockStart) + const inputBlock = sessionSource.slice(inputBlockStart, inputBlockEnd) + expect(inputBlock).toContain('!isGestureMouseTrackingMode(modes?.mouseTrackingMode)') + expect(inputBlock).toContain('const sequenceCount = countTerminalGestureInputSequences(bytes)') + expect(inputBlock.indexOf('countTerminalGestureInputSequences')).toBeLessThan( + inputBlock.indexOf('enqueueTerminalGestureInput') + ) + }) }) diff --git a/mobile/src/transport/pairing.test.ts b/mobile/src/transport/pairing.test.ts new file mode 100644 index 00000000000..5b0a39bf037 --- /dev/null +++ b/mobile/src/transport/pairing.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { extractPairingCodeFromUrl } from './pairing' + +describe('pairing deep links', () => { + it('extracts the QR pairing code from the hash payload', () => { + expect(extractPairingCodeFromUrl('orca://pair#abc123')).toBe('abc123') + }) + + it('extracts the pairing code from a query param', () => { + expect(extractPairingCodeFromUrl('orca://pair?code=abc123')).toBe('abc123') + }) + + it('prefers the query pairing code when both query and hash are present', () => { + expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code') + }) + + it('ignores empty and unrelated URLs', () => { + expect(extractPairingCodeFromUrl('orca://pair')).toBeNull() + expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull() + }) +}) diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index 2b54f4e58db..a3a22b8bf6e 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -7,15 +7,36 @@ import { PairingOfferSchema, type PairingOffer } from './types' export function decodePairingUrl(url: string): PairingOffer | null { try { - const hashIndex = url.indexOf('#') - if (!url.startsWith('orca://pair') || hashIndex === -1) return null - return decodePairingBase64(url.slice(hashIndex + 1)) + const code = extractPairingCodeFromUrl(url) + if (!code) return null + return decodePairingBase64(code) } catch { return null } } -// Why: accept either an `orca://pair#` URL or the bare base64 +// Why: system camera apps hand us the raw custom-scheme URL. Keeping +// extraction here makes QR scan, paste, and external deep-link flows +// accept the same URL shapes. +export function extractPairingCodeFromUrl(url: string): string | null { + if (!url.startsWith('orca://pair')) return null + const queryIndex = url.indexOf('?') + if (queryIndex !== -1) { + const query = url.slice(queryIndex + 1).split('#')[0] ?? '' + const params = new URLSearchParams(query) + const code = params.get('code') + if (code) { + return code + } + } + const hashIndex = url.indexOf('#') + if (hashIndex !== -1) { + return url.slice(hashIndex + 1) || null + } + return null +} + +// Why: accept either an `orca://pair?...` URL or the bare base64 // string so the paste-pair flow can take whichever the user actually // copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { diff --git a/package.json b/package.json index 99609060790..01f24ea7769 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.35", + "version": "1.4.36-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/resources/linux/bin/orca b/resources/linux/bin/orca-ide old mode 100644 new mode 100755 similarity index 94% rename from resources/linux/bin/orca rename to resources/linux/bin/orca-ide index 1a4f8b316a5..94609a3628f --- a/resources/linux/bin/orca +++ b/resources/linux/bin/orca-ide @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Why: invoked via ~/.local/bin/orca symlink (from CliInstaller); resolve it +# Why: invoked via ~/.local/bin/orca-ide symlink (from CliInstaller); resolve it # so APP_DIR points at the real install, not the symlink's parent. SOURCE="${BASH_SOURCE[0]}" while [ -h "$SOURCE" ]; do diff --git a/resources/openclaude-logo.png b/resources/openclaude-logo.png new file mode 100644 index 00000000000..270e21b9b0a Binary files /dev/null and b/resources/openclaude-logo.png differ diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index e7b5a4eafab..7e5f3a2123d 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -20,9 +20,13 @@ description: >- Use this skill when the task should go through Orca's control plane rather than directly through `git`, shell PTYs, or ad hoc filesystem access. +## Platform Note + +On Linux, the CLI command is `orca-ide` (not `orca`) to avoid conflicting with GNOME Orca, the accessibility screen reader. Everywhere this document says `orca `, Linux users should substitute `orca-ide `. macOS and Windows are unaffected. + ## When To Use -Use `orca` for: +Use `orca` (or `orca-ide` on Linux) for: - worktree orchestration inside a running Orca app - updating the current worktree comment with meaningful progress checkpoints @@ -30,7 +34,7 @@ Use `orca` for: - stopping or waiting on Orca-managed terminals - creating and managing scheduled Orca automations - accessing repos known to Orca -Do not use `orca` when plain shell tools are simpler and Orca state does not matter. +Do not use `orca` / `orca-ide` when plain shell tools are simpler and Orca state does not matter. Examples: @@ -42,27 +46,30 @@ Examples: ## Preconditions -- Prefer the public `orca` command first +- Prefer the public `orca` command first (`orca-ide` on Linux) - Orca editor/runtime should already be running, or the agent should start it with `orca open` -- Do not begin by inspecting Orca source files just to decide how to invoke the CLI. The first step is to check whether the installed `orca` command exists. +- Do not begin by inspecting Orca source files just to decide how to invoke the CLI. The first step is to check whether the installed `orca` / `orca-ide` command exists. - Do not assume a generic shell environment variable proves the agent is "inside Orca". For normal agent flows, the public CLI is the supported surface, but avoid wasting a round trip on probe-only checks when a direct Orca action would answer the question. First verify the public CLI is installed: ```bash +# macOS / Windows command -v orca +# Linux +command -v orca-ide ``` Then use the public command: ```bash -orca status --json +orca status --json # or orca-ide on Linux ``` If the task is about Orca worktrees or Orca terminals, do this before any codebase exploration: ```bash -command -v orca +command -v orca # or orca-ide on Linux orca status --json ``` @@ -72,7 +79,7 @@ If the agent truly needs to confirm that the current directory is inside an Orca orca worktree current --json ``` -If `orca` is not on PATH, say so explicitly and stop or ask the user to install/register the CLI before continuing. +If `orca` / `orca-ide` is not on PATH, say so explicitly and stop or ask the user to install/register the CLI before continuing. ## Core Workflow @@ -237,7 +244,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea - Treat `orca worktree set --worktree active --comment ... --json` as a default coding-agent behavior whenever the agent reaches a meaningful checkpoint in the current Orca-managed worktree; the user does not need to explicitly ask for each update. - Update the worktree comment at significant checkpoints, not every trivial command. Good checkpoints include reproducing a bug, confirming a hypothesis, starting a risky migration, finishing a meaningful implementation slice, switching from investigation to fix, or blocking on external input. - Write comments as short status snapshots of the current state, for example `debugging AWS CLI profile resolution`, `confirmed flaky test is caused by temp-dir race`, or `fix implemented; running integration tests`. -- Prefer optimistic execution over probe-first flows for checkpoint updates: if `orca` is on `PATH`, call `orca worktree set --worktree active --comment ... --json` directly at the checkpoint instead of spending an extra cycle on `orca worktree current`. +- Prefer optimistic execution over probe-first flows for checkpoint updates: if `orca` (or `orca-ide` on Linux) is on `PATH`, call `orca worktree set --worktree active --comment ... --json` directly at the checkpoint instead of spending an extra cycle on `orca worktree current`. - If that direct update fails because Orca is unavailable or the shell is not inside an Orca-managed worktree, continue the main task and treat the comment update as best-effort unless the user explicitly made Orca state part of the task. - Use `orca worktree current --json` only when the agent actually needs the worktree identity for later logic, not as a preflight before every comment update. - Orca only injects `ORCA_WORKTREE_PATH`-style variables for some setup-hook flows, so they are not a general detection contract for agents. @@ -249,12 +256,12 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea - Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. In local Orca sessions, `--command "codex"` is routed through Orca's visible terminal path automatically so Codex does not start as a headless/background PTY. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching. - Use `terminal split` to create split panes within an existing terminal tab. Pass `--command` to run a command in the new pane. - Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists. -- If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals. -- If a command fails, prefer retrying with the public `orca` command before concluding the CLI is broken, unless the failure already came from `orca` itself. +- If the user asks for CLI UX feedback, test the public `orca` / `orca-ide` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals. +- If a command fails, prefer retrying with the public `orca` / `orca-ide` command before concluding the CLI is broken, unless the failure already came from the CLI itself. ## Browser Automation -The `orca` CLI also drives the built-in Orca browser. The core workflow is a **snapshot-interact-re-snapshot** loop: +The `orca` CLI (or `orca-ide` on Linux) also drives the built-in Orca browser. The core workflow is a **snapshot-interact-re-snapshot** loop: 1. **Snapshot** the page to see interactive elements and their refs. 2. **Interact** using refs (`@e1`, `@e3`, etc.) to click, fill, or select. @@ -626,7 +633,7 @@ When `orca tab create` opens a new tab, it is automatically set as the active ta - Terminal handles are ephemeral and tied to the current Orca runtime. If Orca restarts, handles change. - `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified. Real coding tasks routinely take 15-60 minutes — always pass `--timeout-ms` explicitly. - Orca is the source of truth for worktree/terminal state; do not duplicate that state with manual assumptions. -- The public `orca` command is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints. +- The public `orca` command (`orca-ide` on Linux) is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints. - The default bounded `terminal read` preview is for status monitoring. For retained transcript extraction, use `terminal read --json` with `oldestCursor`/`nextCursor`, `--cursor`, and `--limit`. ## References diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 86e682922ef..9a22023be36 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -28,7 +28,7 @@ Use `orca-cli` instead for ordinary terminal control, shell commands, browser au ## Preconditions - Orca must be running (`orca status --json` should return `runtime: true`). -- The `orca` CLI must be on PATH (installed via Settings > Browser > Enable Orca CLI). +- The `orca` CLI must be on PATH (`orca-ide` on Linux; installed via Settings > Browser > Enable Orca CLI). - The orchestration experimental feature must be enabled in Settings > Experimental. - All `orca orchestration` commands are RPC calls to the running Orca runtime — they require an active Orca session. @@ -92,7 +92,7 @@ orca orchestration dispatch --task --to [--from ] [-- orca orchestration dispatch-show --task [--json] ``` -Why: `--inject` sends a preamble that teaches the agent how to use `orca orchestration send --type worker_done` to report completion. All agents have `orca` on PATH and can execute shell commands. The preamble maximizes structured feedback but the system works without it (coordinator falls back to idle detection + output reading). +Why: `--inject` sends a preamble that teaches the agent how to use `orca orchestration send --type worker_done` to report completion. All agents have `orca` (or `orca-ide` on Linux) on PATH and can execute shell commands. The preamble maximizes structured feedback but the system works without it (coordinator falls back to idle detection + output reading). Why: `--inject` requires a recognized agent CLI (e.g. Claude Code) running in the target terminal. If the terminal is a bare shell, omit `--inject` and send the prompt manually with `terminal send`. diff --git a/src/cli/help.ts b/src/cli/help.ts index 1e1202dc345..50de2d06059 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -209,7 +209,7 @@ Wait Options: Output Options: --json Emit machine-readable JSON instead of human text - --pairing-code Connect to a remote Orca runtime using an orca://pair#... code + --pairing-code Connect to a remote Orca runtime using an orca://pair?... code --environment Connect using a saved environment id or name --help Show this help message diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index aaff8ec224c..aa930341eee 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -209,7 +209,7 @@ function resolveRemotePairing( if (!pairing) { throw new RuntimeClientError( 'invalid_argument', - 'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.' + 'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } return pairing diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts index c6d88d010d5..ddbe431248c 100644 --- a/src/cli/runtime/transport.test.ts +++ b/src/cli/runtime/transport.test.ts @@ -109,9 +109,9 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => { // A generous timeout: a passing fix rejects on close well before this; the // pre-fix behavior would hang the full duration and trip vitest's own limit. const start = Date.now() - await expect( - sendRequest(metadata, 'status.get', undefined, 60000) - ).rejects.toMatchObject({ code: 'runtime_unavailable' }) + await expect(sendRequest(metadata, 'status.get', undefined, 60000)).rejects.toMatchObject({ + code: 'runtime_unavailable' + }) expect(Date.now() - start).toBeLessThan(5000) }) }) diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 38ccf06f956..3ad50666be8 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -66,7 +66,10 @@ describe('CLI remote WebSocket transport', () => { deviceToken: runtime.deviceToken, publicKeyB64: runtime.publicKeyB64 } - const barePayload = encodePairingOffer(offer).split('#')[1]! + const pairingUrl = encodePairingOffer(offer) + const barePayload = new URLSearchParams(pairingUrl.slice(pairingUrl.indexOf('?') + 1)).get( + 'code' + )! const client = new RuntimeClient('/tmp/unused', 5_000, barePayload) const status = await client.getCliStatus() diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index 6bfe1940b65..2b10baff97e 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -7,7 +7,7 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ summary: 'Save a remote Orca runtime environment from a pairing code', usage: 'orca environment add --name --pairing-code [--json]', allowedFlags: [...GLOBAL_FLAGS, 'name'], - examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...'] + examples: ['orca environment add --name work-laptop --pairing-code orca://pair?code=...'] }, { path: ['environment', 'list'], diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index ac427172753..20a73d753ec 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -250,7 +250,8 @@ async function resolveGenerationTarget( return { kind: 'remote', cwd: worktreePath, - execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' } } diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 98df61b8a4f..cbe70dbf45d 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -1,6 +1,7 @@ import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { HookInstallAgent } from '../../shared/telemetry-events' import type { GlobalSettings } from '../../shared/types' +import { ampHookService } from '../amp/hook-service' import { antigravityHookService } from '../antigravity/hook-service' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' @@ -11,6 +12,7 @@ import { commandCodeHookService } from '../command-code/hook-service' import { geminiHookService } from '../gemini/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void] type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] @@ -18,9 +20,11 @@ type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstal export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [ ['claude', () => claudeHookService.install()], + ['openclaude', () => openClaudeHookService.install()], ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], ['antigravity', () => antigravityHookService.install()], + ['amp', () => ampHookService.install()], ['cursor', () => cursorHookService.install()], ['droid', () => droidHookService.install()], ['command-code', () => commandCodeHookService.install()], @@ -31,9 +35,11 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ ['claude', () => claudeHookService.remove()], + ['openclaude', () => openClaudeHookService.remove()], ['codex', () => codexHookService.remove()], ['gemini', () => geminiHookService.remove()], ['antigravity', () => antigravityHookService.remove()], + ['amp', () => ampHookService.remove()], ['cursor', () => cursorHookService.remove()], ['droid', () => droidHookService.remove()], ['command-code', () => commandCodeHookService.remove()], @@ -44,9 +50,11 @@ const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ ['claude', () => claudeHookService.getStatus()], + ['openclaude', () => openClaudeHookService.getStatus()], ['codex', () => codexHookService.getStatus()], ['gemini', () => geminiHookService.getStatus()], ['antigravity', () => antigravityHookService.getStatus()], + ['amp', () => ampHookService.getStatus()], ['cursor', () => cursorHookService.getStatus()], ['droid', () => droidHookService.getStatus()], ['command-code', () => commandCodeHookService.getStatus()], diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 86eb35a9f83..b9c917e9cd8 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -13,10 +13,12 @@ import { CursorHookService } from '../cursor/hook-service' import { CommandCodeHookService } from '../command-code/hook-service' import { GeminiHookService } from '../gemini/hook-service' import { AntigravityHookService } from '../antigravity/hook-service' +import { AmpHookService } from '../amp/hook-service' import { ClaudeHookService } from '../claude/hook-service' import { GrokHookService } from '../grok/hook-service' import { CopilotHookService } from '../copilot/hook-service' import { HermesHookService } from '../hermes/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' type FakeFs = { files: Map @@ -122,6 +124,10 @@ describe('remote hook service installers', () => { path: '/home/dev/.orca/agent-hooks/claude-hook.sh', install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, '/home/dev') }, + { + path: '/home/dev/.orca/agent-hooks/openclaude-hook.sh', + install: (sftp: SFTPWrapper) => openClaudeHookService.installRemote(sftp, '/home/dev') + }, { path: '/home/dev/.orca/agent-hooks/codex-hook.sh', install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, '/home/dev') @@ -135,6 +141,10 @@ describe('remote hook service installers', () => { install: (sftp: SFTPWrapper) => new AntigravityHookService().installRemote(sftp, '/home/dev') }, + { + path: '/home/dev/.config/amp/plugins/orca-agent-status.ts', + install: (sftp: SFTPWrapper) => new AmpHookService().installRemote(sftp, '/home/dev') + }, { path: '/home/dev/.orca/agent-hooks/cursor-hook.sh', install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev') @@ -159,7 +169,12 @@ describe('remote hook service installers', () => { const status = await install(sftp) expect(status.state).toBe('installed') const script = fs.files.get(path) - expect(script).toMatch(/^#!\/bin\/sh\n/) + if (path.includes('/.config/amp/plugins/')) { + expect(script).toContain('/hook/amp') + expect(script).toContain("amp.on('agent.start'") + } else { + expect(script).toMatch(/^#!\/bin\/sh\n/) + } expect(script).not.toContain('@echo off') expect(script).not.toContain('powershell -NoProfile') } @@ -215,12 +230,14 @@ describe('remote hook service installers', () => { it('installs remote Gemini, Antigravity, Cursor, Command Code, and Grok configs using their CLI-specific schemas', async () => { const gemini = createFakeSftp() const antigravity = createFakeSftp() + const amp = createFakeSftp() const cursor = createFakeSftp() const commandCode = createFakeSftp() const grok = createFakeSftp() await new GeminiHookService().installRemote(gemini.sftp, '/home/dev') await new AntigravityHookService().installRemote(antigravity.sftp, '/home/dev') + await new AmpHookService().installRemote(amp.sftp, '/home/dev') await new CursorHookService().installRemote(cursor.sftp, '/home/dev') await new CommandCodeHookService().installRemote(commandCode.sftp, '/home/dev') await new GrokHookService().installRemote(grok.sftp, '/home/dev') @@ -257,6 +274,11 @@ describe('remote hook service installers', () => { expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`) } + const ampPlugin = amp.fs.files.get('/home/dev/.config/amp/plugins/orca-agent-status.ts') + expect(ampPlugin).toContain('/hook/amp') + expect(ampPlugin).toContain("amp.on('tool.call'") + expect(ampPlugin).toContain('return { action: "allow" }') + const cursorConfig = JSON.parse(cursor.fs.files.get('/home/dev/.cursor/hooks.json')!) as { version: number hooks: Record @@ -473,4 +495,22 @@ describe('remote hook service installers', () => { ) expect(fs.files.get('/home/dev/.hermes/config.yaml')).toContain('orca-status') }) + + it('does not overwrite a remote user-authored Amp plugin file', async () => { + const { sftp, fs } = createFakeSftp({ + '/home/dev/.config/amp/plugins/orca-agent-status.ts': + 'export default function userPlugin() {}\n' + }) + + const status = await new AmpHookService().installRemote(sftp, '/home/dev/') + + expect(status).toMatchObject({ + agent: 'amp', + state: 'partial', + managedHooksPresent: false + }) + expect(fs.files.get('/home/dev/.config/amp/plugins/orca-agent-status.ts')).toBe( + 'export default function userPlugin() {}\n' + ) + }) }) diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 7100bec01b1..a199b1596b4 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -1,5 +1,6 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { ampHookService } from '../amp/hook-service' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' @@ -8,6 +9,7 @@ import { cursorHookService } from '../cursor/hook-service' import { commandCodeHookService } from '../command-code/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' type RemoteManagedHookInstaller = readonly [ AgentHookInstallStatus['agent'], @@ -16,9 +18,11 @@ type RemoteManagedHookInstaller = readonly [ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)], + ['openclaude', (sftp, remoteHome) => openClaudeHookService.installRemote(sftp, remoteHome)], ['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], + ['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], ['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)], ['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 926f24d8903..994f7fff4e5 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2176,6 +2176,364 @@ describe('AgentHookServer listener replay', () => { server.stop() } }) + + it('accepts Amp plugin hook posts on /hook/amp', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/amp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'agent.start', + message: 'verify Amp route' + }) + ) + }) + expect(response.status).toBe(204) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: expect.objectContaining({ + state: 'working', + prompt: 'verify Amp route', + agentType: 'amp' + }) + }) + ) + } finally { + server.stop() + } + }) +}) + +describe('Amp hook normalization', () => { + it('maps agent lifecycle events to working and done states', () => { + const start = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + message: 'wire Amp hooks' + }), + 'production' + ) + + expect(start?.payload).toMatchObject({ + state: 'working', + prompt: 'wire Amp hooks', + agentType: 'amp' + }) + + const done = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + message: 'wire Amp hooks', + status: 'done' + }), + 'production' + ) + + expect(done?.payload).toMatchObject({ + state: 'done', + prompt: 'wire Amp hooks', + agentType: 'amp' + }) + }) + + it('surfaces Amp tool call and result context while preserving the prompt', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + message: 'run tests' + }), + 'production' + ) + + const toolCall = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.call', + tool: 'shell_command', + input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' } + }), + 'production' + ) + + expect(toolCall?.payload).toMatchObject({ + state: 'working', + prompt: 'run tests', + agentType: 'amp', + toolName: 'shell_command', + toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts' + }) + + const result = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + tool: 'shell_command', + input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' }, + status: 'done', + output: 'tests passed' + }), + 'production' + ) + + expect(result?.payload).toMatchObject({ + state: 'working', + prompt: 'run tests', + agentType: 'amp', + toolName: 'shell_command', + toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts', + lastAssistantMessage: 'tests passed' + }) + }) + + it('does not let Amp tool result messages overwrite the cached prompt', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + message: 'run tests' + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + tool: 'shell_command', + input: { command: 'pnpm test' }, + message: 'tests passed' + }), + 'production' + ) + + expect(result?.payload).toMatchObject({ + state: 'working', + prompt: 'run tests', + agentType: 'amp', + lastAssistantMessage: 'tests passed' + }) + + const done = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + status: 'done' + }), + 'production' + ) + + expect(done?.payload).toMatchObject({ + state: 'done', + prompt: 'run tests', + agentType: 'amp' + }) + }) + + it('keeps Amp prompt and tool caches isolated by thread id within one pane', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + threadId: 'thread-a', + message: 'first task' + }), + 'production' + ) + + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + threadId: 'thread-b', + message: 'second task' + }), + 'production' + ) + + const threadAResult = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + threadId: 'thread-a', + tool: 'shell_command', + input: { command: 'pnpm test:a' }, + output: 'first done' + }), + 'production' + ) + + expect(threadAResult?.payload).toMatchObject({ + state: 'working', + prompt: 'first task', + agentType: 'amp', + toolName: 'shell_command', + toolInput: 'pnpm test:a', + lastAssistantMessage: 'first done' + }) + + const threadBDone = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + threadId: 'thread-b', + status: 'done' + }), + 'production' + ) + + expect(threadBDone?.payload).toMatchObject({ + state: 'done', + prompt: 'second task', + agentType: 'amp' + }) + }) + + it('drops stale Amp tool events that arrive after the thread ended', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + threadId: 'thread-a', + message: 'run tests' + }), + 'production' + ) + + const done = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + threadId: 'thread-a', + status: 'done' + }), + 'production' + ) + + expect(done?.payload).toMatchObject({ + state: 'done', + prompt: 'run tests', + agentType: 'amp' + }) + + const staleToolResult = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + threadId: 'thread-a', + tool: 'shell_command', + input: { command: 'pnpm test' }, + message: 'tests passed' + }), + 'production' + ) + + expect(staleToolResult).toBeNull() + }) + + it('does not mark Amp tool result messages as explicit prompts', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + threadId: 'thread-a', + message: 'run tests' + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + threadId: 'thread-a', + tool: 'shell_command', + input: { command: 'pnpm test' }, + message: 'tests passed' + }), + 'production' + ) + + expect(result?.payload).toMatchObject({ + state: 'working', + prompt: 'run tests', + agentType: 'amp', + lastAssistantMessage: 'tests passed' + }) + expect(result?.hasExplicitPrompt).toBeUndefined() + }) + + it('marks cancelled Amp turns as interrupted done states', () => { + const cancelled = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + message: 'stop this run', + status: 'cancelled' + }), + 'production' + ) + + expect(cancelled?.payload).toMatchObject({ + state: 'done', + prompt: 'stop this run', + agentType: 'amp', + interrupted: true + }) + }) + + it('treats session.start as cache reset without creating a visible row', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + message: 'old prompt' + }), + 'production' + ) + + const sessionStart = _internals.normalizeHookPayload( + 'amp', + buildBody({ hook_event_name: 'session.start', threadId: 'thread-1' }), + 'production' + ) + expect(sessionStart).toBeNull() + + const nextTool = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.call', + tool: 'Read', + input: { file_path: '/tmp/file.ts' } + }), + 'production' + ) + + expect(nextTool?.payload).toMatchObject({ + state: 'working', + prompt: '', + agentType: 'amp', + toolName: 'Read', + toolInput: '/tmp/file.ts' + }) + }) }) describe('AgentHookServer prompt-sent telemetry', () => { @@ -2950,6 +3308,32 @@ describe('Claude hook normalization', () => { expect(result?.payload.lastAssistantMessage).toBe('what is up my dude') }) + it('StopFailure maps to done without copying provider error text', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'say hi' + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'StopFailure', + error: 'invalid_request', + error_details: 'model is not supported', + last_assistant_message: 'API Error: model is not supported' + }), + 'production' + ) + + expect(result?.payload.state).toBe('done') + expect(result?.payload.prompt).toBe('say hi') + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + describe('Stop transcript scan', () => { let tmpDir: string let transcriptPath: string diff --git a/src/main/amp/hook-service.test.ts b/src/main/amp/hook-service.test.ts new file mode 100644 index 00000000000..9c83074d9a1 --- /dev/null +++ b/src/main/amp/hook-service.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { dirname, join } from 'path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('os', async () => { + const actual = (await vi.importActual('os')) as Record + return { + ...actual, + homedir: homedirMock + } +}) + +import { AmpHookService, _internals } from './hook-service' + +describe('AmpHookService', () => { + let homeDir: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-amp-home-')) + homedirMock.mockReturnValue(homeDir) + }) + + afterEach(() => { + vi.clearAllMocks() + rmSync(homeDir, { recursive: true, force: true }) + }) + + it('installs an Orca-managed Amp system plugin', () => { + const status = new AmpHookService().install() + + expect(status).toMatchObject({ + agent: 'amp', + state: 'installed', + configPath: join(homeDir, '.config', 'amp', 'plugins', _internals.AMP_PLUGIN_FILE), + managedHooksPresent: true, + detail: null + }) + + const source = readFileSync(status.configPath, 'utf-8') + expect(source).toContain(_internals.AMP_PLUGIN_MARKER) + expect(source).toContain('/hook/amp') + expect(source).toContain("amp.on('session.start'") + expect(source).toContain("amp.on('agent.start'") + expect(source).toContain("amp.on('tool.call'") + expect(source).toContain("amp.on('tool.result'") + expect(source).toContain("amp.on('agent.end'") + expect(source).toContain('return { action: "allow" }') + expect(source).toContain('let postQueue = Promise.resolve()') + expect(source).toContain('function enqueuePost') + expect(source).toContain('enqueuePost("tool.call"') + expect(source).not.toContain('await post("tool.call"') + expect(source).toContain('process.env.ORCA_PANE_KEY') + expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT') + }) + + it('does not overwrite an existing user-authored Amp plugin file', () => { + const pluginPath = _internals.getPluginPath() + mkdirSync(dirname(pluginPath), { recursive: true }) + writeFileSync(pluginPath, 'export default function userPlugin() {}\n', 'utf-8') + + const status = new AmpHookService().install() + + expect(status).toMatchObject({ + agent: 'amp', + state: 'partial', + managedHooksPresent: false + }) + expect(readFileSync(pluginPath, 'utf-8')).toBe('export default function userPlugin() {}\n') + }) + + it('removes only Orca-managed Amp plugin files', () => { + const service = new AmpHookService() + const installed = service.install() + expect(existsSync(installed.configPath)).toBe(true) + + const removed = service.remove() + + expect(removed.state).toBe('not_installed') + expect(existsSync(installed.configPath)).toBe(false) + + const pluginPath = _internals.getPluginPath() + mkdirSync(dirname(pluginPath), { recursive: true }) + writeFileSync(pluginPath, 'export default function userPlugin() {}\n', 'utf-8') + + const skipped = service.remove() + + expect(skipped.state).toBe('partial') + expect(existsSync(pluginPath)).toBe(true) + }) + + it('reports partial for stale managed plugin content missing required handlers', () => { + const pluginPath = _internals.getPluginPath() + mkdirSync(dirname(pluginPath), { recursive: true }) + writeFileSync(pluginPath, `// ${_internals.AMP_PLUGIN_MARKER}\n`, 'utf-8') + + const status = new AmpHookService().getStatus() + + expect(status).toMatchObject({ + agent: 'amp', + state: 'partial', + managedHooksPresent: true + }) + expect(status.detail).toContain('missing required handlers') + }) + + it('reports partial when a stale managed plugin is missing the session reset handler', () => { + const pluginPath = _internals.getPluginPath() + mkdirSync(dirname(pluginPath), { recursive: true }) + writeFileSync( + pluginPath, + [ + `// ${_internals.AMP_PLUGIN_MARKER}`, + "amp.on('agent.start', () => {})", + "amp.on('tool.call', () => {})", + "amp.on('tool.result', () => {})", + "amp.on('agent.end', () => {})", + '/hook/amp', + '' + ].join('\n'), + 'utf-8' + ) + + const status = new AmpHookService().getStatus() + + expect(status).toMatchObject({ + agent: 'amp', + state: 'partial', + managedHooksPresent: true + }) + expect(status.detail).toContain('missing required handlers') + }) +}) diff --git a/src/main/amp/hook-service.ts b/src/main/amp/hook-service.ts new file mode 100644 index 00000000000..93b0e066fc0 --- /dev/null +++ b/src/main/amp/hook-service.ts @@ -0,0 +1,367 @@ +/* eslint-disable max-lines -- Why: install/status/remove must share the exact + managed Amp plugin source and ownership marker. Splitting would make the + emitted plugin bytes drift from the installer checks that protect user + plugin files from being overwritten. */ +import { randomUUID } from 'crypto' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { dirname, join } from 'path' +import type { SFTPWrapper } from 'ssh2' + +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + readTextFileRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' + +const AMP_PLUGIN_FILE = 'orca-agent-status.ts' +const AMP_PLUGIN_MARKER = 'Managed by Orca. Do not edit; changes may be overwritten.' + +type PluginFileState = + | { kind: 'absent' } + | { kind: 'managed'; complete: boolean } + | { kind: 'unmanaged' } + | { kind: 'error'; detail: string } + +function getPluginPath(): string { + return join(homedir(), '.config', 'amp', 'plugins', AMP_PLUGIN_FILE) +} + +function getRemotePluginPath(remoteHome: string): string { + const home = remoteHome.replace(/\/$/, '') + return `${home}/.config/amp/plugins/${AMP_PLUGIN_FILE}` +} + +function isManagedPlugin(content: string): boolean { + return content.includes(AMP_PLUGIN_MARKER) +} + +function isCompleteManagedPlugin(content: string): boolean { + return ( + isManagedPlugin(content) && + content.includes('/hook/amp') && + content.includes("amp.on('session.start'") && + content.includes("amp.on('agent.start'") && + content.includes("amp.on('tool.call'") && + content.includes("amp.on('tool.result'") && + content.includes("amp.on('agent.end'") + ) +} + +function readLocalPluginState(pluginPath: string): PluginFileState { + if (!existsSync(pluginPath)) { + return { kind: 'absent' } + } + try { + const content = readFileSync(pluginPath, 'utf-8') + if (!isManagedPlugin(content)) { + return { kind: 'unmanaged' } + } + return { kind: 'managed', complete: isCompleteManagedPlugin(content) } + } catch (error) { + return { kind: 'error', detail: error instanceof Error ? error.message : String(error) } + } +} + +function statusFromState(pluginPath: string, state: PluginFileState): AgentHookInstallStatus { + switch (state.kind) { + case 'absent': + return { + agent: 'amp', + state: 'not_installed', + configPath: pluginPath, + managedHooksPresent: false, + detail: null + } + case 'managed': + return { + agent: 'amp', + state: state.complete ? 'installed' : 'partial', + configPath: pluginPath, + managedHooksPresent: true, + detail: state.complete ? null : 'Managed Amp plugin is missing required handlers' + } + case 'unmanaged': + return { + agent: 'amp', + state: 'partial', + configPath: pluginPath, + managedHooksPresent: false, + detail: 'Amp Orca status plugin exists but is not Orca-managed' + } + case 'error': + return { + agent: 'amp', + state: 'error', + configPath: pluginPath, + managedHooksPresent: false, + detail: state.detail + } + } +} + +function writeTextFileAtomic(filePath: string, content: string): void { + const dir = dirname(filePath) + mkdirSync(dir, { recursive: true }) + if (existsSync(filePath)) { + try { + if (readFileSync(filePath, 'utf-8') === content) { + return + } + } catch { + // Fall through to the atomic write path. + } + } + + const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) + try { + writeFileSync(tmpPath, content, 'utf-8') + renameSync(tmpPath, filePath) + } finally { + if (existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // best effort + } + } + } +} + +function getAmpPluginSource(): string { + return [ + "import { readFileSync, statSync } from 'fs'", + "import type { PluginAPI } from '@ampcode/plugin'", + '', + `// ${AMP_PLUGIN_MARKER}`, + 'type HookCoords = { port?: string; token?: string; env?: string; version?: string }', + '', + 'let warnedBadEndpoint = false', + "let cachedEndpointKey = ''", + 'let cachedEndpointValues: HookCoords | null = null', + '', + 'function readEndpointFile(): HookCoords | null {', + ' const endpointPath = process.env.ORCA_AGENT_HOOK_ENDPOINT', + ' if (!endpointPath) return null', + ' try {', + ' const stat = statSync(endpointPath)', + ' const cacheKey = `${stat.mtimeMs}:${stat.size}:${stat.ino}`', + ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', + ' return cachedEndpointValues', + ' }', + " const contents = readFileSync(endpointPath, 'utf8')", + ' const out: HookCoords = {}', + ' for (const line of contents.split(/\\r?\\n/)) {', + ' const match = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/)', + ' if (!match) continue', + ' const value = match[2].replace(/\\r$/, "")', + " if (match[1] === 'ORCA_AGENT_HOOK_PORT') out.port = value", + " if (match[1] === 'ORCA_AGENT_HOOK_TOKEN') out.token = value", + " if (match[1] === 'ORCA_AGENT_HOOK_ENV') out.env = value", + " if (match[1] === 'ORCA_AGENT_HOOK_VERSION') out.version = value", + ' }', + ' cachedEndpointKey = cacheKey', + ' cachedEndpointValues = out', + ' return out', + ' } catch (error) {', + " cachedEndpointKey = ''", + ' cachedEndpointValues = null', + ' if ((error as { code?: unknown })?.code !== "ENOENT" && !warnedBadEndpoint) {', + ' warnedBadEndpoint = true', + " console.warn('[orca-hook] failed to parse Amp endpoint file:', (error as Error).message)", + ' }', + ' return null', + ' }', + '}', + '', + 'function resolveHookCoords(): HookCoords {', + ' // Why: Amp sessions can outlive an Orca restart; the endpoint file is', + ' // rewritten on each start, so read it per event before falling back to env.', + ' const fileEnv = readEndpointFile() ?? {}', + ' return {', + ' port: fileEnv.port || process.env.ORCA_AGENT_HOOK_PORT,', + ' token: fileEnv.token || process.env.ORCA_AGENT_HOOK_TOKEN,', + ' env: fileEnv.env || process.env.ORCA_AGENT_HOOK_ENV || "",', + ' version: fileEnv.version || process.env.ORCA_AGENT_HOOK_VERSION || ""', + ' }', + '}', + '', + 'function previewValue(value: unknown, maxLength = 4000): string | undefined {', + ' if (typeof value === "string") return value.slice(0, maxLength)', + ' if (value === null || value === undefined) return undefined', + ' try {', + ' return JSON.stringify(value).slice(0, maxLength)', + ' } catch {', + ' return String(value).slice(0, maxLength)', + ' }', + '}', + '', + 'function jsonSafe(value: unknown, depth = 0): unknown {', + ' if (value === null || value === undefined) return value', + ' if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {', + ' return value', + ' }', + ' if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function") {', + ' return String(value)', + ' }', + ' if (depth >= 4) return previewValue(value)', + ' if (Array.isArray(value)) return value.slice(0, 20).map((item) => jsonSafe(item, depth + 1))', + ' if (typeof value === "object") {', + ' const out: Record = {}', + ' for (const [key, child] of Object.entries(value).slice(0, 20)) {', + ' out[key] = jsonSafe(child, depth + 1)', + ' }', + ' return out', + ' }', + ' return String(value)', + '}', + '', + 'async function post(hookEventName: string, payload: Record): Promise {', + ' const coords = resolveHookCoords()', + ' const paneKey = process.env.ORCA_PANE_KEY', + ' if (!coords.port || !coords.token || !paneKey) return', + ' const controller = new AbortController()', + ' const timeout = setTimeout(() => controller.abort(), 1000)', + ' try {', + ' await fetch(`http://127.0.0.1:${coords.port}/hook/amp`, {', + ' method: "POST",', + ' signal: controller.signal,', + ' headers: {', + ' "Content-Type": "application/json",', + ' "X-Orca-Agent-Hook-Token": coords.token', + ' },', + ' body: JSON.stringify({', + ' paneKey,', + ' tabId: process.env.ORCA_TAB_ID || "",', + ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', + ' env: coords.env,', + ' version: coords.version,', + ' hook_event_name: hookEventName,', + ' payload: { hook_event_name: hookEventName, ...payload }', + ' })', + ' })', + ' } catch {', + ' // Why: Orca status reporting must never affect the Amp run.', + ' } finally {', + ' clearTimeout(timeout)', + ' }', + '}', + '', + 'let postQueue = Promise.resolve()', + 'function enqueuePost(hookEventName: string, payload: Record): void {', + ' // Why: keep hook callbacks non-blocking while preserving Amp event order.', + ' postQueue = postQueue.then(() => post(hookEventName, payload), () => post(hookEventName, payload))', + ' void postQueue.catch(() => {})', + '}', + '', + 'export default function (amp: PluginAPI) {', + " amp.on('session.start', (event) => {", + ' enqueuePost("session.start", { threadId: event.thread.id })', + ' })', + '', + " amp.on('agent.start', (event) => {", + ' enqueuePost("agent.start", {', + ' threadId: event.thread.id,', + ' id: event.id,', + ' message: event.message', + ' })', + ' })', + '', + " amp.on('tool.call', (event) => {", + ' enqueuePost("tool.call", {', + ' threadId: event.thread.id,', + ' toolUseId: event.toolUseID,', + ' tool: event.tool,', + ' input: jsonSafe(event.input)', + ' })', + ' return { action: "allow" }', + ' })', + '', + " amp.on('tool.result', (event) => {", + ' enqueuePost("tool.result", {', + ' threadId: event.thread.id,', + ' toolUseId: event.toolUseID,', + ' tool: event.tool,', + ' input: jsonSafe(event.input),', + ' status: event.status,', + ' error: event.error,', + ' output: previewValue(event.output)', + ' })', + ' })', + '', + " amp.on('agent.end', (event) => {", + ' enqueuePost("agent.end", {', + ' threadId: event.thread.id,', + ' id: event.id,', + ' message: event.message,', + ' status: event.status', + ' })', + ' })', + '}', + '' + ].join('\n') +} + +export class AmpHookService { + getStatus(): AgentHookInstallStatus { + const pluginPath = getPluginPath() + return statusFromState(pluginPath, readLocalPluginState(pluginPath)) + } + + install(): AgentHookInstallStatus { + const pluginPath = getPluginPath() + const state = readLocalPluginState(pluginPath) + if (state.kind === 'unmanaged' || state.kind === 'error') { + return statusFromState(pluginPath, state) + } + writeTextFileAtomic(pluginPath, getAmpPluginSource()) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remotePluginPath = getRemotePluginPath(remoteHome) + try { + const existing = await readTextFileRemote(sftp, remotePluginPath) + if (existing !== null && !isManagedPlugin(existing)) { + return statusFromState(remotePluginPath, { kind: 'unmanaged' }) + } + await writeTextFileRemoteAtomic(sftp, remotePluginPath, getAmpPluginSource()) + return { + agent: 'amp', + state: 'installed', + configPath: remotePluginPath, + managedHooksPresent: true, + detail: null + } + } catch (error) { + return { + agent: 'amp', + state: 'error', + configPath: remotePluginPath, + managedHooksPresent: false, + detail: error instanceof Error ? error.message : String(error) + } + } + } + + remove(): AgentHookInstallStatus { + const pluginPath = getPluginPath() + const state = readLocalPluginState(pluginPath) + if (state.kind === 'managed') { + unlinkSync(pluginPath) + return this.getStatus() + } + return statusFromState(pluginPath, state) + } +} + +export const ampHookService = new AmpHookService() + +export const _internals = { + AMP_PLUGIN_FILE, + AMP_PLUGIN_MARKER, + getAmpPluginSource, + getPluginPath, + getRemotePluginPath, + isManagedPlugin +} diff --git a/src/main/browser/browser-grab-session-controller.ts b/src/main/browser/browser-grab-session-controller.ts index 152460abef5..fafef11c25c 100644 --- a/src/main/browser/browser-grab-session-controller.ts +++ b/src/main/browser/browser-grab-session-controller.ts @@ -21,6 +21,35 @@ type ActiveGrabOp = { /** Hard timeout for an armed grab operation to prevent indefinite hangs. */ const GRAB_OP_TIMEOUT_MS = 120_000 +function isGuestCancellationPayload(rawPayload: unknown): boolean { + if (!rawPayload || typeof rawPayload !== 'object') { + return false + } + const payload = rawPayload as Record + if (payload.__orcaCancelled === true) { + return true + } + // Why: old guest/Electron paths can serialize cancellation as a plain error + // object, but valid grab payloads may also carry page-authored fields. + if (payload.message !== 'cancelled') { + return false + } + return !('page' in payload) && !('target' in payload) && !('payload' in payload) +} + +function getGuestErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message + } + if (err && typeof err === 'object') { + const message = (err as Record).message + if (typeof message === 'string') { + return message + } + } + return 'Selection failed' +} + export class BrowserGrabSessionController { private readonly activeGrabOps = new Map() @@ -105,6 +134,12 @@ export class BrowserGrabSessionController { settleOnce({ opId, kind: 'cancelled', reason: 'user' }) return } + // Why: teardown cancellation is an expected user path. Classify it + // before payload validation so it cannot surface as an invalid grab. + if (isGuestCancellationPayload(rawPayload)) { + settleOnce({ opId, kind: 'cancelled', reason: 'user' }) + return + } // Why: the guest wraps right-click results in { __orcaContextMenu, payload } // so the renderer can show the full action dropdown instead of auto-copying. const isContextMenu = @@ -124,7 +159,7 @@ export class BrowserGrabSessionController { payload }) } catch (err) { - const message = err instanceof Error ? err.message : 'Selection failed' + const message = getGuestErrorMessage(err) if (message.includes('cancelled')) { settleOnce({ opId, kind: 'cancelled', reason: 'user' }) } else { diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index c293c37c521..b3c16e08165 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -4,6 +4,7 @@ main-side payload validation. Splitting across files would scatter the shared mock setup and make it harder to verify the grab contract holistically. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { GRAB_BUDGET } from '../../shared/browser-grab-types' +import type { BrowserGrabPayload } from '../../shared/browser-grab-types' const { webContentsFromIdMock, @@ -62,6 +63,57 @@ function makeGuest(id: number) { } as unknown as Electron.WebContents } +function makeValidGrabPayload(): BrowserGrabPayload { + return { + page: { + sanitizedUrl: 'https://example.com/', + title: 'Example', + viewportWidth: 1280, + viewportHeight: 720, + scrollX: 0, + scrollY: 0, + devicePixelRatio: 2, + capturedAt: '2026-04-10T00:00:00.000Z' + }, + target: { + tagName: 'button', + selector: 'button', + textSnippet: 'Click me', + htmlSnippet: '', + attributes: {}, + accessibility: { + role: 'button', + accessibleName: 'Click me', + ariaLabel: null, + ariaLabelledBy: null + }, + rectViewport: { x: 0, y: 0, width: 100, height: 40 }, + rectPage: { x: 0, y: 0, width: 100, height: 40 }, + computedStyles: { + display: 'block', + position: 'static', + width: '100px', + height: '40px', + margin: '0', + padding: '0', + color: '#000', + backgroundColor: '#fff', + border: 'none', + borderRadius: '0', + fontFamily: 'sans-serif', + fontSize: '14px', + fontWeight: '400', + lineHeight: '20px', + textAlign: 'left', + zIndex: 'auto' + } + }, + nearbyText: [], + ancestorPath: ['div', 'body'], + screenshot: null + } +} + describe('browserManager grab operations', () => { const rendererWebContentsId = 5001 const primaryModifier = @@ -341,6 +393,40 @@ describe('browserManager grab operations', () => { expect(result.kind).toBe('cancelled') }) + it('resolves with cancelled when guest returns teardown cancellation marker', async () => { + guestExecuteJavaScriptMock.mockResolvedValueOnce({ __orcaCancelled: true }) + + const result = await browserManager.awaitGrabSelection('tab-1', 'op-1', guest) + expect(result).toEqual({ opId: 'op-1', kind: 'cancelled', reason: 'user' }) + }) + + it('resolves with cancelled when guest returns a serialized cancelled error', async () => { + guestExecuteJavaScriptMock.mockResolvedValueOnce({ message: 'cancelled' }) + + const result = await browserManager.awaitGrabSelection('tab-1', 'op-1', guest) + expect(result).toEqual({ opId: 'op-1', kind: 'cancelled', reason: 'user' }) + }) + + it('resolves with cancelled when executeJavaScript rejects a serialized cancelled error', async () => { + guestExecuteJavaScriptMock.mockRejectedValueOnce({ message: 'cancelled' }) + + const result = await browserManager.awaitGrabSelection('tab-1', 'op-1', guest) + expect(result).toEqual({ opId: 'op-1', kind: 'cancelled', reason: 'user' }) + }) + + it('does not treat a valid payload message field as cancellation', async () => { + guestExecuteJavaScriptMock.mockResolvedValueOnce({ + ...makeValidGrabPayload(), + message: 'cancelled' + }) + + const result = await browserManager.awaitGrabSelection('tab-1', 'op-1', guest) + expect(result.kind).toBe('selected') + if (result.kind === 'selected') { + expect(result.payload.target.tagName).toBe('button') + } + }) + it('resolves with error when executeJavaScript throws', async () => { guestExecuteJavaScriptMock.mockRejectedValueOnce(new Error('Script failed')) diff --git a/src/main/browser/grab-guest-script.test.ts b/src/main/browser/grab-guest-script.test.ts index 176f355f1e8..e8705c0edd8 100644 --- a/src/main/browser/grab-guest-script.test.ts +++ b/src/main/browser/grab-guest-script.test.ts @@ -97,6 +97,7 @@ describe('buildGuestOverlayScript', () => { it('teardown script cancels pending awaitClick', () => { const script = buildGuestOverlayScript('teardown') expect(script).toContain('cancelAwait') + expect(buildGuestOverlayScript('awaitClick')).toContain('__orcaCancelled') }) it('arm script uses full-viewport overlay as click catcher', () => { diff --git a/src/main/browser/grab-guest-script.ts b/src/main/browser/grab-guest-script.ts index 35fbdb5f0b2..9817bc187ac 100644 --- a/src/main/browser/grab-guest-script.ts +++ b/src/main/browser/grab-guest-script.ts @@ -838,12 +838,14 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) { grab.host.addEventListener('click', onClick, true); grab.host.addEventListener('contextmenu', onContext, true); - // Store cancel hook so teardown can reject the Promise + // Store cancel hook so teardown can settle the Promise grab.cancelAwait = function() { grab.host.removeEventListener('click', onClick, true); grab.host.removeEventListener('contextmenu', onContext, true); grab.cleanup(); - reject(new Error('cancelled')); + // Why: teardown cancellation is a normal user flow; resolving a marker + // avoids a noisy guest-console Error while main still treats it as cancel. + resolve({ __orcaCancelled: true }); }; })` diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 233db78d2b8..962e4795a02 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -3132,7 +3132,10 @@ describe('ClaudeRuntimeAuthService', () => { const service = new ClaudeRuntimeAuthService(store as never) const preparation = await service.prepareForClaudeLaunch() - expect(store.updateSettings).toHaveBeenCalledWith({ activeClaudeManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) expect(preparation.stripAuthEnv).toBe(false) expect(preparation.provenance).toBe('system') @@ -3337,6 +3340,99 @@ describe('ClaudeRuntimeAuthService', () => { expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) }) + it('clears a selected WSL managed account when its credentials are missing', async () => { + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/account-1/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(store.updateSettings).toHaveBeenCalledWith({ + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(preparation.runtime).toBe('wsl') + expect(preparation.provenance).toBe('wsl:Ubuntu:system') + expect(preparation.stripAuthEnv).toBe(true) + }) + + it('uses the default distro selection for WSL-default Claude preparation', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => join(testState.userDataDir, 'wsl-home') + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const debianAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'debian-account', + createClaudeCredentialsJson('debian@example.com', 'debian-token') + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }), + createClaudeAccount('debian-account', debianAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/debian/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } + } + }) + const store = createStore(settings) + + try { + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch({ + runtime: 'wsl', + wslDistro: null + }) + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', + provenance: 'managed:ubuntu-account:wsl:Ubuntu', + stripAuthEnv: true + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index f41a0fbc6bc..2a2081bb695 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: Claude account switching has one safety boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and env-patch semantics together prevents PTY launch and quota fetch paths drifting. */ +import { execFileSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { app } from 'electron' @@ -13,6 +14,9 @@ import { resolveOwnedClaudeManagedAuthPath, writeClaudeManagedAuthFile } from './managed-auth-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' import { hasLiveClaudePtys } from './live-pty-gate' import { ClaudeRuntimePathResolver } from './runtime-paths' import { @@ -24,9 +28,19 @@ import { writeActiveClaudeKeychainCredentialsForRuntime, writeManagedClaudeKeychainCredentials } from './keychain' +import { + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + normalizeClaudeRuntimeSelection, + setSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' export type ClaudeRuntimeAuthPreparation = { configDir: string + runtime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch stripAuthEnv: boolean provenance: string @@ -64,6 +78,10 @@ type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class ClaudeRuntimeAuthService { private readonly pathResolver = new ClaudeRuntimePathResolver() private mutationQueue: Promise = Promise.resolve() @@ -83,18 +101,22 @@ export class ClaudeRuntimeAuthService { void this.safeSyncForCurrentSelection() } - async prepareForClaudeLaunch(): Promise { - await this.syncForCurrentSelection() - return this.getPreparation() + async prepareForClaudeLaunch( + target?: ClaudeAccountSelectionTarget + ): Promise { + await this.syncForCurrentSelection(target) + return this.getPreparation(target) } - async prepareForRateLimitFetch(): Promise { - await this.syncForCurrentSelection() - return this.getPreparation() + async prepareForRateLimitFetch( + target?: ClaudeAccountSelectionTarget + ): Promise { + await this.syncForCurrentSelection(target) + return this.getPreparation(target) } - async syncForCurrentSelection(): Promise { - await this.serializeMutation(() => this.doSyncForCurrentSelection()) + async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { + await this.serializeMutation(() => this.doSyncForCurrentSelection(target)) } async forceMaterializeCurrentSelectionForRollback(): Promise { @@ -122,7 +144,7 @@ export class ClaudeRuntimeAuthService { private initializeLastSyncedState(): void { const settings = this.store.getSettings() - this.lastSyncedAccountId = settings.activeClaudeManagedAccountId + this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) } private async safeSyncForCurrentSelection(): Promise { @@ -139,12 +161,12 @@ export class ClaudeRuntimeAuthService { return next } - private async doSyncForCurrentSelection(): Promise { + private async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.claudeManagedAccounts, - settings.activeClaudeManagedAccountId - ) + const effectiveTarget = this.resolveWslDefaultTarget(target) + const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget) + const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) + const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) const previousAccount = this.getActiveAccount( settings.claudeManagedAccounts, this.lastSyncedAccountId @@ -163,8 +185,20 @@ export class ClaudeRuntimeAuthService { } } if (!activeAccount) { - if (settings.activeClaudeManagedAccountId) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) + if (activeAccountId) { + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + } + if (normalizedTarget.runtime === 'wsl') { + return } if (this.lastSyncedAccountId !== null) { await (previousAccount @@ -178,6 +212,47 @@ export class ClaudeRuntimeAuthService { return } + if (activeAccount.managedAuthRuntime === 'wsl') { + if (!this.getOwnedManagedAuthPath(activeAccount)) { + console.warn( + '[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default' + ) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + return + } + const credentialsJson = await this.readManagedCredentials(activeAccount) + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { + console.warn( + '[claude-runtime-auth] Active WSL managed account is missing or has invalid credentials, restoring system default' + ) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(settings), + null, + normalizedTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + return + } + // Why: WSL managed Claude accounts are already isolated by their Linux + // CLAUDE_CONFIG_DIR. Materializing them into Windows ~/.claude would mix + // two runtime auth stores and break the Terminal-default runtime contract. + this.clearLastWrittenRuntimeState() + return + } + if (!this.getOwnedManagedAuthPath(activeAccount)) { console.warn( '[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default' @@ -446,15 +521,74 @@ export class ClaudeRuntimeAuthService { return candidates } - private getPreparation(): ClaudeRuntimeAuthPreparation { + private getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation { const settings = this.store.getSettings() const paths = this.pathResolver.getRuntimePaths() - const activeAccountId = settings.activeClaudeManagedAccountId + const normalizedTarget = this.resolveWslDefaultTarget( + target ?? + (process.platform === 'win32' && settings.terminalWindowsShell === 'wsl.exe' + ? ({ + runtime: 'wsl', + wslDistro: settings.terminalWindowsWslDistro ?? null + } satisfies ClaudeAccountSelectionTarget) + : ({ runtime: 'host' } satisfies ClaudeAccountSelectionTarget)) + ) + const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) + const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) + if ( + normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' && + activeAccount?.managedAuthRuntime === 'wsl' && + activeAccount.wslLinuxAuthPath + ) { + return { + configDir: activeAccount.managedAuthPath, + runtime: 'wsl', + wslDistro: activeAccount.wslDistro ?? null, + wslLinuxConfigDir: activeAccount.wslLinuxAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: activeAccount.wslLinuxAuthPath }, + stripAuthEnv: true, + provenance: `managed:${activeAccount.id}:wsl:${activeAccount.wslDistro ?? ''}` + } + } + if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') { + const distro = + normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro() + const wslHome = distro ? getWslHome(distro) : null + const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null + if (distro && wslHome && wslHomeInfo) { + const windowsConfigDir = join(wslHome, '.claude') + const linuxConfigDir = `${wslHomeInfo.linuxPath.replace(/\/$/, '')}/.claude` + return { + configDir: windowsConfigDir, + runtime: 'wsl', + wslDistro: distro, + wslLinuxConfigDir: linuxConfigDir, + envPatch: {}, + stripAuthEnv: true, + provenance: `wsl:${distro}:system` + } + } + return { + configDir: paths.configDir, + runtime: 'wsl', + wslDistro: normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system` + } + } return { configDir: paths.configDir, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, envPatch: paths.envPatch, - stripAuthEnv: Boolean(activeAccountId), - provenance: activeAccountId ? `managed:${activeAccountId}` : 'system' + stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'), + provenance: + activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' + ? `managed:${activeAccountId}` + : 'system' } } @@ -468,6 +602,16 @@ export class ClaudeRuntimeAuthService { return accounts.find((account) => account.id === activeAccountId) ?? null } + private resolveWslDefaultTarget( + target?: ClaudeAccountSelectionTarget + ): ClaudeAccountSelectionTarget { + if (target?.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target ?? { runtime: 'host' } + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } + private async findManagedAccountForRuntimeCredentials( runtimeCredentialsJson: string ): Promise { @@ -773,6 +917,46 @@ export class ClaudeRuntimeAuthService { } private getOwnedManagedAuthPath(account: ClaudeManagedAccount): string | null { + const wslInfo = parseWslUncPath(account.managedAuthPath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + return null + } + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`, + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null + } catch { + return null + } + } + return existsSync(account.managedAuthPath) ? account.managedAuthPath : null + } return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { adoptLegacyMarker: true }) diff --git a/src/main/claude-accounts/runtime-selection.test.ts b/src/main/claude-accounts/runtime-selection.test.ts new file mode 100644 index 00000000000..997da720de1 --- /dev/null +++ b/src/main/claude-accounts/runtime-selection.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types' +import { + getSelectedClaudeAccountIdForTarget, + pruneInvalidClaudeRuntimeSelection, + setSelectedClaudeAccountIdForTarget +} from './runtime-selection' + +function createSettings( + overrides: Partial< + Pick + > = {} +): Pick { + return { + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} }, + ...overrides + } +} + +function createAccount( + overrides: Partial & Pick +): ClaudeManagedAccount { + const { id, ...rest } = overrides + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/tmp/${id}`, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...rest + } +} + +describe('Claude runtime account selection', () => { + it('selects host and WSL accounts independently', () => { + const first = setSelectedClaudeAccountIdForTarget({ host: null, wsl: {} }, 'host-account', { + runtime: 'host' + }) + const next = setSelectedClaudeAccountIdForTarget(first, 'wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + + it('resolves a WSL default target when exactly one WSL distro has a selection', () => { + const settings = createSettings({ + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + + expect(getSelectedClaudeAccountIdForTarget(settings, { runtime: 'wsl' })).toBe('wsl-account') + expect(getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })).toBe('host-account') + }) + + it('clears WSL selections without clearing the host selection for a WSL default target', () => { + const next = setSelectedClaudeAccountIdForTarget( + { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account', Debian: 'other-wsl-account' } + }, + null, + { runtime: 'wsl' } + ) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null, Debian: null } + }) + }) + + it('drops selections whose account belongs to another runtime', () => { + const selection = pruneInvalidClaudeRuntimeSelection( + { + host: 'wsl-account', + wsl: { Ubuntu: 'host-account', Debian: 'missing-account' } + }, + [ + createAccount({ id: 'host-account' }), + createAccount({ + id: 'wsl-account', + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + ] + ) + + expect(selection).toEqual({ + host: null, + wsl: { Ubuntu: null, Debian: null } + }) + }) +}) diff --git a/src/main/claude-accounts/runtime-selection.ts b/src/main/claude-accounts/runtime-selection.ts new file mode 100644 index 00000000000..8f3a3a4397b --- /dev/null +++ b/src/main/claude-accounts/runtime-selection.ts @@ -0,0 +1,147 @@ +import type { + ClaudeManagedAccount, + ClaudeManagedAccountRuntimeSelection, + GlobalSettings +} from '../../shared/types' + +export type ClaudeAccountSelectionTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type NormalizedClaudeAccountSelectionTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +export function normalizeClaudeAccountSelectionTarget( + target?: ClaudeAccountSelectionTarget | null +): NormalizedClaudeAccountSelectionTarget { + if (target?.runtime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: normalizeWslDistro(target.wslDistro) + } + } + return { runtime: 'host', wslDistro: null } +} + +export function normalizeClaudeRuntimeSelection( + settings: Pick< + GlobalSettings, + 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime' + > +): ClaudeManagedAccountRuntimeSelection { + return { + host: + settings.activeClaudeManagedAccountIdsByRuntime?.host ?? + settings.activeClaudeManagedAccountId ?? + null, + wsl: { ...settings.activeClaudeManagedAccountIdsByRuntime?.wsl } + } +} + +export function getSelectedClaudeAccountIdForTarget( + settings: Pick< + GlobalSettings, + 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime' + >, + target?: ClaudeAccountSelectionTarget | null +): string | null { + const selection = normalizeClaudeRuntimeSelection(settings) + const normalizedTarget = normalizeClaudeAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return selection.host + } + if (normalizedTarget.wslDistro) { + return selection.wsl[getClaudeWslSelectionKey(normalizedTarget.wslDistro)] ?? null + } + const selectedIds = Array.from(new Set(Object.values(selection.wsl).filter(Boolean))) + return ( + selection.wsl[getClaudeWslSelectionKey(null)] ?? + (selectedIds.length === 1 ? selectedIds[0] : null) + ) +} + +export function setSelectedClaudeAccountIdForTarget( + selection: ClaudeManagedAccountRuntimeSelection, + accountId: string | null, + target?: ClaudeAccountSelectionTarget | null +): ClaudeManagedAccountRuntimeSelection { + const normalizedTarget = normalizeClaudeAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return { host: accountId, wsl: { ...selection.wsl } } + } + if (accountId === null && normalizedTarget.wslDistro === null) { + return { + host: selection.host, + wsl: Object.fromEntries(Object.keys(selection.wsl).map((key) => [key, null])) + } + } + return { + host: selection.host, + wsl: { + ...selection.wsl, + [getClaudeWslSelectionKey(normalizedTarget.wslDistro)]: accountId + } + } +} + +export function removeClaudeAccountIdFromSelection( + selection: ClaudeManagedAccountRuntimeSelection, + accountId: string +): ClaudeManagedAccountRuntimeSelection { + const nextWsl: Record = {} + for (const [distro, selectedId] of Object.entries(selection.wsl)) { + nextWsl[distro] = selectedId === accountId ? null : selectedId + } + return { + host: selection.host === accountId ? null : selection.host, + wsl: nextWsl + } +} + +export function pruneInvalidClaudeRuntimeSelection( + selection: ClaudeManagedAccountRuntimeSelection, + accounts: ClaudeManagedAccount[] +): ClaudeManagedAccountRuntimeSelection { + const hostAccount = selection.host + ? accounts.find((account) => account.id === selection.host) + : null + const nextWsl: Record = {} + for (const [distroKey, accountId] of Object.entries(selection.wsl)) { + if (!accountId) { + nextWsl[distroKey] = null + continue + } + const account = accounts.find((entry) => entry.id === accountId) + nextWsl[distroKey] = + account && + account.managedAuthRuntime === 'wsl' && + getClaudeWslSelectionKey(account.wslDistro) === distroKey + ? accountId + : null + } + return { + host: hostAccount && hostAccount.managedAuthRuntime !== 'wsl' ? selection.host : null, + wsl: nextWsl + } +} + +export function getClaudeSelectionTargetForAccount( + account: ClaudeManagedAccount +): ClaudeAccountSelectionTarget { + if (account.managedAuthRuntime === 'wsl') { + return { runtime: 'wsl', wslDistro: account.wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getClaudeWslSelectionKey(wslDistro: string | null | undefined): string { + return normalizeWslDistro(wslDistro) ?? '__default__' +} + +function normalizeWslDistro(wslDistro: string | null | undefined): string | null { + const trimmed = wslDistro?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index aa449677843..423b6aa9992 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -505,7 +505,9 @@ describe('ClaudeAccountService credential capture', () => { await service.removeAccount('account-1') expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith() + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('account-1', { + runtime: 'host' + }) expect(settings).toMatchObject({ claudeManagedAccounts: [], activeClaudeManagedAccountId: null @@ -576,7 +578,229 @@ describe('ClaudeAccountService credential capture', () => { await service.reauthenticateAccount('account-1') expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') - expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith() + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(undefined, { + runtime: 'host' + }) expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com') }) + + it('selects a WSL account without changing the Windows active account', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + mkdirSync(wslAuthPath, { recursive: true }) + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + const snapshot = await service.selectAccountForTarget('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(snapshot.activeAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith(null, { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) + + it('rejects selecting a WSL account for the Windows target', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(wslAuthPath, { recursive: true }) + const settings = { + claudeManagedAccounts: [ + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn() + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await expect( + service.selectAccountForTarget('wsl-account', { runtime: 'host' }) + ).rejects.toThrow('different runtime') + expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() + expect(rateLimits.refreshForClaudeAccountChange).not.toHaveBeenCalled() + }) + + it('removes a WSL account without clearing the Windows active account', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + mkdirSync(wslAuthPath, { recursive: true }) + writeFileSync(join(wslAuthPath, '.orca-managed-claude-auth'), 'wsl-account\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedAuthPath: wslAuthPath, + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/jin/.local/share/orca/claude-accounts/wsl-account/auth', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await service.removeAccount('wsl-account') + + expect(settings.activeClaudeManagedAccountId).toBe('host-account') + expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null } + }) + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('wsl-account') + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) }) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 688af804f23..c09183c94fb 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ import { randomUUID } from 'node:crypto' -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative, resolve, sep } from 'node:path' @@ -30,6 +30,19 @@ import { writeManagedClaudeKeychainCredentials } from './keychain' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + getClaudeSelectionTargetForAccount, + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + normalizeClaudeRuntimeSelection, + pruneInvalidClaudeRuntimeSelection, + removeClaudeAccountIdFromSelection, + setSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' const LOGIN_TIMEOUT_MS = 180_000 const STATUS_TIMEOUT_MS = 20_000 @@ -52,6 +65,22 @@ type ManagedClaudeAuthSnapshot = { oauthAccountJson: string | null } +export type ClaudeAccountAddTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +type ManagedClaudeAuthLocation = { + managedAuthPath: string + managedAuthRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxAuthPath: string | null +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() @@ -66,8 +95,8 @@ export class ClaudeAccountService { return this.getSnapshot() } - async addAccount(): Promise { - return this.serializeMutation(() => this.doAddAccount()) + async addAccount(target?: ClaudeAccountAddTarget): Promise { + return this.serializeMutation(() => this.doAddAccount(target)) } async reauthenticateAccount(accountId: string): Promise { @@ -82,19 +111,29 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.doSelectAccount(accountId)) } + async selectAccountForTarget( + accountId: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + return this.serializeMutation(() => this.doSelectAccount(accountId, target)) + } + private serializeMutation(fn: () => Promise): Promise { const next = this.mutationQueue.then(fn, fn) this.mutationQueue = next.catch(() => {}) return next } - private async doAddAccount(): Promise { + private async doAddAccount( + target?: ClaudeAccountAddTarget + ): Promise { const accountId = randomUUID() - const managedAuthPath = this.createManagedAuthDir(accountId) + const managedAuth = this.createManagedAuthDir(accountId, target) + const { managedAuthPath } = managedAuth const previousSettings = this.store.getSettings() try { - const captured = await this.runClaudeLoginAndCapture() + const captured = await this.runClaudeLoginAndCapture(managedAuth) if (!captured.identity.email) { throw new Error('Claude login completed, but Orca could not resolve the account email.') } @@ -105,6 +144,9 @@ export class ClaudeAccountService { id: accountId, email: captured.identity.email, managedAuthPath, + managedAuthRuntime: managedAuth.managedAuthRuntime, + wslDistro: managedAuth.wslDistro, + wslLinuxAuthPath: managedAuth.wslLinuxAuthPath, authMethod: 'subscription-oauth', organizationUuid: captured.identity.organizationUuid, organizationName: captured.identity.organizationName, @@ -113,14 +155,25 @@ export class ClaudeAccountService { lastAuthenticatedAt: now } - const outgoingAccountId = previousSettings.activeClaudeManagedAccountId + const selection = normalizeClaudeRuntimeSelection(previousSettings) + const targetSelection = getClaudeSelectionTargetForAccount(account) + const outgoingAccountId = getSelectedClaudeAccountIdForTarget( + previousSettings, + targetSelection + ) this.store.updateSettings({ claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: account.id + activeClaudeManagedAccountId: + targetSelection.runtime === 'host' ? account.id : selection.host, + activeClaudeManagedAccountIdsByRuntime: setSelectedClaudeAccountIdForTarget( + selection, + account.id, + targetSelection + ) }) this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) + await this.syncRuntimeAuthWithLivePtyGate(targetSelection) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, targetSelection) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) @@ -135,7 +188,12 @@ export class ClaudeAccountService { const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath, accountId) const previousSettings = this.store.getSettings() const previousManagedAuth = await this.readManagedAuthSnapshot(accountId, managedAuthPath) - const captured = await this.runClaudeLoginAndCapture() + const captured = await this.runClaudeLoginAndCapture({ + managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + wslLinuxAuthPath: account.wslLinuxAuthPath ?? null + }) if (!captured.identity.email) { throw new Error('Claude login completed, but Orca could not resolve the account email.') } @@ -162,8 +220,11 @@ export class ClaudeAccountService { this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts }) this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) this.rateLimits.evictInactiveClaudeCache(accountId) - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange() + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) + await this.rateLimits.refreshForClaudeAccountChange( + undefined, + getClaudeSelectionTargetForAccount(account) + ) return this.getSnapshot() } catch (error) { let restoredManagedCredentials = false @@ -206,26 +267,45 @@ export class ClaudeAccountService { const account = this.requireAccount(accountId) const settings = this.store.getSettings() const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) + const nextSelection = removeClaudeAccountIdFromSelection( + normalizeClaudeRuntimeSelection(settings), + accountId + ) const nextActiveId = - settings.activeClaudeManagedAccountId === accountId - ? null - : settings.activeClaudeManagedAccountId + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host try { - if (settings.activeClaudeManagedAccountId === accountId) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) - await this.syncRuntimeAuthWithLivePtyGate() + if ( + getSelectedClaudeAccountIdForTarget( + settings, + getClaudeSelectionTargetForAccount(account) + ) === accountId + ) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) } else { this.store.updateSettings({ claudeManagedAccounts: nextAccounts, - activeClaudeManagedAccountId: nextActiveId + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection }) - await this.syncRuntimeAuthWithLivePtyGate() + await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) } await this.safeRemoveManagedAuth(accountId, account.managedAuthPath) this.rateLimits.evictInactiveClaudeCache(accountId) - await this.rateLimits.refreshForClaudeAccountChange() + await this.rateLimits.refreshForClaudeAccountChange( + getSelectedClaudeAccountIdForTarget( + settings, + getClaudeSelectionTargetForAccount(account) + ) === accountId + ? accountId + : undefined, + getClaudeSelectionTargetForAccount(account) + ) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(settings) @@ -234,16 +314,37 @@ export class ClaudeAccountService { } } - private async doSelectAccount(accountId: string | null): Promise { + private async doSelectAccount( + accountId: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + let effectiveTarget = target if (accountId !== null) { - this.requireAccount(accountId) + const account = this.requireAccount(accountId) + const accountTarget = getClaudeSelectionTargetForAccount(account) + const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) + if ( + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + ) { + throw new Error('That Claude account belongs to a different runtime.') + } + effectiveTarget = accountTarget } const previousSettings = this.store.getSettings() - const outgoingAccountId = previousSettings.activeClaudeManagedAccountId - this.store.updateSettings({ activeClaudeManagedAccountId: accountId }) + const selection = normalizeClaudeRuntimeSelection(previousSettings) + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) + const nextSelection = setSelectedClaudeAccountIdForTarget(selection, accountId, effectiveTarget) + this.store.updateSettings({ + activeClaudeManagedAccountId: + effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) try { - await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) + await this.syncRuntimeAuthWithLivePtyGate(effectiveTarget) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) @@ -258,7 +359,8 @@ export class ClaudeAccountService { accounts: settings.claudeManagedAccounts .map((account) => this.toSummary(account)) .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: settings.activeClaudeManagedAccountId + activeAccountId: normalizeClaudeRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) } } @@ -266,6 +368,8 @@ export class ClaudeAccountService { return { id: account.id, email: account.email, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, authMethod: account.authMethod ?? 'unknown', organizationUuid: account.organizationUuid ?? null, organizationName: account.organizationName ?? null, @@ -287,54 +391,73 @@ export class ClaudeAccountService { private normalizeActiveSelection(): void { const settings = this.store.getSettings() - if (!settings.activeClaudeManagedAccountId) { - return - } - const hasActiveAccount = settings.claudeManagedAccounts.some( - (entry) => entry.id === settings.activeClaudeManagedAccountId + const nextSelection = pruneInvalidClaudeRuntimeSelection( + normalizeClaudeRuntimeSelection(settings), + settings.claudeManagedAccounts ) - if (!hasActiveAccount) { - this.store.updateSettings({ activeClaudeManagedAccountId: null }) + if ( + nextSelection.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(nextSelection) !== JSON.stringify(normalizeClaudeRuntimeSelection(settings)) + ) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) } } private restoreClaudeSettings(settings: ReturnType): void { this.store.updateSettings({ claudeManagedAccounts: settings.claudeManagedAccounts, - activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId + activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime }) } - private async syncRuntimeAuthWithLivePtyGate(operation?: () => Promise): Promise { + private async syncRuntimeAuthWithLivePtyGate( + target?: ClaudeAccountSelectionTarget, + operation?: () => Promise + ): Promise { beginClaudeAuthSwitch() try { - await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection()) + await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) } finally { endClaudeAuthSwitch() } } - private async runClaudeLoginAndCapture(): Promise { - const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) + private async runClaudeLoginAndCapture( + location: ManagedClaudeAuthLocation = { + managedAuthPath: '', + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + ): Promise { + const tempConfig = this.createTemporaryClaudeConfigDir(location) const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() let captured: CapturedClaudeAuth | null = null let captureError: unknown = null let cleanupError: unknown = null try { - await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfigDir, LOGIN_TIMEOUT_MS) + await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS) const status = await this.runClaudeCommand( ['auth', 'status', '--json'], - tempConfigDir, + tempConfig, STATUS_TIMEOUT_MS, { allowFailure: true } ) - captured = await this.captureAuthFromConfigDir(tempConfigDir, status, previousLegacyKeychain) + captured = await this.captureAuthFromConfigDir( + tempConfig.windowsPath, + status, + previousLegacyKeychain + ) } catch (error) { captureError = error } finally { if (process.platform === 'darwin') { try { - await deleteActiveClaudeKeychainCredentialsStrict(tempConfigDir) + await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) } catch (error) { console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) } @@ -350,7 +473,7 @@ export class ClaudeAccountService { cleanupError = error } } - rmSync(tempConfigDir, { recursive: true, force: true }) + this.removeTemporaryClaudeConfigDir(tempConfig) } if (captureError) { throw captureError @@ -361,6 +484,72 @@ export class ClaudeAccountService { return captured! } + private createTemporaryClaudeConfigDir(location: ManagedClaudeAuthLocation): { + windowsPath: string + linuxPath: string | null + wslDistro: string | null + } { + if (location.managedAuthRuntime !== 'wsl') { + return { + windowsPath: mkdtempSync(join(tmpdir(), 'orca-claude-login-')), + linuxPath: null, + wslDistro: null + } + } + if (!location.wslDistro) { + throw new Error('Could not resolve the active WSL distribution for Claude login.') + } + const linuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + location.wslDistro, + '--', + 'bash', + '-lc', + 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"' + ], + { encoding: 'utf-8', timeout: 5000 } + ) + .replaceAll(String.fromCharCode(0), '') + .trim() + if (!linuxPath.startsWith('/')) { + throw new Error('Could not create a temporary WSL Claude login directory.') + } + return { + windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), + linuxPath, + wslDistro: location.wslDistro + } + } + + private removeTemporaryClaudeConfigDir(tempConfig: { + windowsPath: string + linuxPath: string | null + wslDistro: string | null + }): void { + if (tempConfig.linuxPath && tempConfig.wslDistro) { + try { + execFileSync( + 'wsl.exe', + [ + '-d', + tempConfig.wslDistro, + '--', + 'bash', + '-lc', + `rm -rf -- ${shellQuote(tempConfig.linuxPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + } catch { + // Best-effort cleanup. + } + return + } + rmSync(tempConfig.windowsPath, { recursive: true, force: true }) + } + private async captureAuthFromConfigDir( configDir: string, statusOutput: string, @@ -520,11 +709,72 @@ export class ClaudeAccountService { } } - private createManagedAuthDir(accountId: string): string { + private createManagedAuthDir( + accountId: string, + target?: ClaudeAccountAddTarget + ): ManagedClaudeAuthLocation { + const wslAuth = this.tryCreateWslManagedAuthDir(accountId, target) + if (wslAuth) { + return wslAuth + } + const managedAuthPath = join(this.getManagedAccountsRoot(), accountId, 'auth') mkdirSync(managedAuthPath, { recursive: true }) writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') - return this.assertManagedAuthPath(managedAuthPath, accountId) + return { + managedAuthPath: this.assertManagedAuthPath(managedAuthPath, accountId), + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + } + + private tryCreateWslManagedAuthDir( + accountId: string, + target?: ClaudeAccountAddTarget + ): ManagedClaudeAuthLocation | null { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + + const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : [] + const infoOutput = execFileSync( + 'wsl.exe', + [...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], + { encoding: 'utf-8', timeout: 5000 } + ) + const [rawDistro, rawHome] = infoOutput + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + const distro = target.wslDistro?.trim() || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Claude login.') + } + + const wslLinuxAuthPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` + const markerPath = `${wslLinuxAuthPath}/.orca-managed-claude-auth` + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + `mkdir -p ${shellQuote(wslLinuxAuthPath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + + const managedAuthPath = toWindowsWslPath(wslLinuxAuthPath, distro) + return { + managedAuthPath: this.assertManagedAuthPath(managedAuthPath, accountId), + managedAuthRuntime: 'wsl', + wslDistro: distro, + wslLinuxAuthPath + } } private getManagedAccountsRoot(): string { @@ -534,6 +784,60 @@ export class ClaudeAccountService { } private assertManagedAuthPath(candidatePath: string, expectedAccountId?: string): string { + const wslInfo = parseWslUncPath(candidatePath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') + } + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + expectedAccountId + ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` + : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"', + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + if (!canonicalLinuxPath) { + throw new Error('Managed Claude auth directory does not exist on disk.') + } + return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { + cause: error + }) + } + } + if ( + !existsSync(candidatePath) || + !existsSync(join(candidatePath, '.orca-managed-claude-auth')) + ) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + return candidatePath + } + this.getManagedAccountsRoot() const accountId = expectedAccountId ?? this.readManagedAuthAccountIdFromPath(candidatePath) if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { @@ -567,19 +871,39 @@ export class ClaudeAccountService { private runClaudeCommand( args: string[], - configDir: string, + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, timeoutMs: number, options?: { allowFailure?: boolean } ): Promise { return new Promise((resolvePromise, rejectPromise) => { - const claudeCommand = resolveClaudeCommand() - const child = spawn(claudeCommand, args, { + const spawnConfig = + configDir.linuxPath && configDir.wslDistro + ? { + command: 'wsl.exe', + args: [ + '-d', + configDir.wslDistro, + '--', + 'bash', + '-lc', + `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` + ], + env: process.env, + shell: false + } + : { + command: resolveClaudeCommand(), + args, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir.windowsPath + }, + shell: process.platform === 'win32' + } + const child = spawn(spawnConfig.command, spawnConfig.args, { stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32', - env: { - ...process.env, - CLAUDE_CONFIG_DIR: configDir - } + shell: spawnConfig.shell, + env: spawnConfig.env }) let settled = false diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 3514e666267..90909cd6468 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -3,7 +3,7 @@ // or the script body that lands on the remote box. Local install behavior // is exercised through `installer-utils.test.ts` and the per-CLI status // audit; this file covers ONLY the SFTP-backed path added in commit #8. -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { vi, describe, expect, it } from 'vitest' @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ import type { SFTPWrapper } from 'ssh2' import { ClaudeHookService } from './hook-service' +import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' type FakeFs = { files: Map @@ -162,6 +163,7 @@ describe('ClaudeHookService.install', () => { command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') ) ).toBe(false) + expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain('claude-hook.sh') } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -187,6 +189,7 @@ describe('ClaudeHookService.installRemote', () => { for (const event of [ 'UserPromptSubmit', 'Stop', + 'StopFailure', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', @@ -246,3 +249,58 @@ describe('ClaudeHookService.installRemote', () => { expect(userCmds.filter((c) => c.includes('claude-hook.sh'))).toHaveLength(1) }) }) + +describe('OpenClaudeHookService-compatible install', () => { + const makeOpenClaudeService = (): ClaudeHookService => + new ClaudeHookService({ + agent: 'openclaude', + displayName: 'OpenClaude', + settings: OPENCLAUDE_HOOK_SETTINGS + }) + + it('installs managed hooks into OpenClaude settings without touching Claude settings', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-openclaude-hooks-')) + vi.stubEnv('HOME', tmpHome) + try { + const openClaudeSettings = join(tmpHome, '.openclaude', 'settings.json') + mkdirSync(join(tmpHome, '.openclaude'), { recursive: true }) + writeFileSync(openClaudeSettings, JSON.stringify({ hooks: {} })) + + const status = makeOpenClaudeService().install() + + expect(status).toMatchObject({ + agent: 'openclaude', + state: 'installed', + configPath: openClaudeSettings + }) + const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8')) + for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) { + const command = parsed.hooks[event][0].hooks[0].command as string + expect(command).toContain('openclaude-hook.sh') + } + expect( + readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'openclaude-hook.sh'), 'utf-8') + ).toContain('/hook/claude') + expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('writes remote OpenClaude settings under .openclaude', async () => { + const { sftp, fs } = createFakeSftp() + + const status = await makeOpenClaudeService().installRemote(sftp, '/home/dev') + + expect(status).toMatchObject({ + agent: 'openclaude', + state: 'installed', + configPath: '/home/dev/.openclaude/settings.json' + }) + const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!) + const command = parsed.hooks.StopFailure[0].hooks[0].command as string + expect(command).toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh') + expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude') + }) +}) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 4fa638b6968..b78bc3e7b73 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -14,14 +14,30 @@ import { import { applyManagedHooks, CLAUDE_EVENTS, + CLAUDE_HOOK_SETTINGS, + getManagedScriptFileName, getConfigPath, getManagedCommand, getManagedScriptPath, + getPosixManagedScriptFileName, getRemoteConfigPath, getRemoteManagedCommand, - removeManagedHooks + removeManagedHooks, + type ClaudeCompatibleHookSettings } from './hook-settings' +type ClaudeHookServiceOptions = { + agent: AgentHookInstallStatus['agent'] + displayName: string + settings: ClaudeCompatibleHookSettings +} + +const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { + agent: 'claude', + displayName: 'Claude', + settings: CLAUDE_HOOK_SETTINGS +} + function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -87,17 +103,23 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class ClaudeHookService { + private readonly options: ClaudeHookServiceOptions + + constructor(options: ClaudeHookServiceOptions = DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS) { + this.options = options + } + getStatus(): AgentHookInstallStatus { - const configPath = getConfigPath() - const scriptPath = getManagedScriptPath() + const configPath = getConfigPath(this.options.settings) + const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath, managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' + detail: `Could not parse ${this.options.displayName} settings.json` } } @@ -134,25 +156,29 @@ export class ClaudeHookService { state = 'partial' detail = `Managed hook missing for events: ${missing.join(', ')}` } - return { agent: 'claude', state, configPath, managedHooksPresent, detail } + return { agent: this.options.agent, state, configPath, managedHooksPresent, detail } } install(): AgentHookInstallStatus { - const configPath = getConfigPath() - const scriptPath = getManagedScriptPath() + const configPath = getConfigPath(this.options.settings) + const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath, managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' + detail: `Could not parse ${this.options.displayName} settings.json` } } const command = getManagedCommand(scriptPath) - const nextConfig = applyManagedHooks(config, command) + const nextConfig = applyManagedHooks( + config, + command, + getManagedScriptFileName(this.options.settings) + ) writeManagedScript(scriptPath, getManagedScript()) writeHooksJson(configPath, nextConfig) return this.getStatus() @@ -166,8 +192,9 @@ export class ClaudeHookService { // and a `.sh` managed script body. The remote platform is gated by the // relay's capability RPC at a higher layer; we cannot detect it from // `process.platform` here (that's the local box). - const remoteConfigPath = getRemoteConfigPath(remoteHome) - const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh` + const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings) + const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings) + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` // Why: SFTP reads/writes fail far more often than local fs (network drops, // EACCES on remote dirs, disk full, channel closed). Wrap the entire // install flow in try/catch so a transient I/O failure surfaces as a @@ -179,18 +206,18 @@ export class ClaudeHookService { const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath: remoteConfigPath, managedHooksPresent: false, - detail: 'Could not parse remote Claude settings.json' + detail: `Could not parse remote ${this.options.displayName} settings.json` } } // Why: the POSIX wrapper is identical regardless of where the script // lands; only the path differs. Reuse the same wrapper helper. const command = getRemoteManagedCommand(remoteScriptPath) - const nextConfig = applyManagedHooks(config, command, 'claude-hook.sh') + const nextConfig = applyManagedHooks(config, command, remoteScriptFileName) // Why: write the script first, then the settings — settings.json // referencing a missing script body would fire `command not found` on @@ -204,7 +231,7 @@ export class ClaudeHookService { await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) return { - agent: 'claude', + agent: this.options.agent, state: 'installed', configPath: remoteConfigPath, managedHooksPresent: true, @@ -212,7 +239,7 @@ export class ClaudeHookService { } } catch (err) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath: remoteConfigPath, managedHooksPresent: false, @@ -222,18 +249,21 @@ export class ClaudeHookService { } remove(): AgentHookInstallStatus { - const configPath = getConfigPath() + const configPath = getConfigPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { return { - agent: 'claude', + agent: this.options.agent, state: 'error', configPath, managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' + detail: `Could not parse ${this.options.displayName} settings.json` } } - const { config: nextConfig, changed } = removeManagedHooks(config) + const { config: nextConfig, changed } = removeManagedHooks( + config, + getManagedScriptFileName(this.options.settings) + ) if (changed) { writeHooksJson(configPath, nextConfig) } diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index e4e3bc07662..f6a7a2a0f73 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -9,9 +9,27 @@ import { type HooksConfig } from '../agent-hooks/installer-utils' +export type ClaudeCompatibleHookSettings = { + configDirName: '.claude' | '.openclaude' + scriptBaseName: 'claude-hook' | 'openclaude-hook' +} + +export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { + configDirName: '.claude', + scriptBaseName: 'claude-hook' +} + +export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { + configDirName: '.openclaude', + scriptBaseName: 'openclaude-hook' +} + export const CLAUDE_EVENTS = [ { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: OpenClaude skips normal Stop hooks after API/model errors and emits + // StopFailure instead; without this hook Orca leaves the turn spinning. + { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: PreToolUse gives the dashboard a live readout of the in-flight tool // (name + input preview) before it completes. { @@ -32,20 +50,26 @@ export const CLAUDE_EVENTS = [ } ] as const -export function getConfigPath(): string { - return join(homedir(), '.claude', 'settings.json') +export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { + return join(homedir(), settings.configDirName, 'settings.json') } -export function getManagedScriptFileName(): string { - return process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' +export function getManagedScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { + return process.platform === 'win32' + ? `${settings.scriptBaseName}.cmd` + : getPosixManagedScriptFileName(settings) } -export function getManagedScriptPath(): string { - return getSharedManagedScriptPath(getManagedScriptFileName()) +export function getPosixManagedScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string { + return `${settings.scriptBaseName}.sh` } -export function getRemoteConfigPath(remoteHome: string): string { - return `${remoteHome.replace(/\/$/, '')}/.claude/settings.json` +export function getManagedScriptPath(settings = CLAUDE_HOOK_SETTINGS): string { + return getSharedManagedScriptPath(getManagedScriptFileName(settings)) +} + +export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_SETTINGS): string { + return `${remoteHome.replace(/\/$/, '')}/${settings.configDirName}/settings.json` } export function getManagedCommand(scriptPath: string): string { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index b0dc2eb2475..43f525b3f94 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises' +import { lstat, mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -33,60 +33,98 @@ describe('CliInstaller', () => { }) // Why: this test creates Unix symlinks and shell scripts that only apply on macOS. - it.skipIf(process.platform === 'win32')('creates a dev launcher and installs a macOS symlink in the requested path', async () => { - const fixture = await makeFixture() - const installPath = join(fixture.root, 'bin', 'orca') - const installer = new CliInstaller({ - platform: 'darwin', - isPackaged: false, - userDataPath: fixture.userDataPath, - execPath: '/Applications/Orca.app/Contents/MacOS/Orca', - appPath: fixture.appPath, - commandPathOverride: installPath, - processPathEnv: join(fixture.root, 'bin') - }) + it.skipIf(process.platform === 'win32')( + 'creates a dev launcher and installs a macOS symlink in the requested path', + async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'bin', 'orca') + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + commandPathOverride: installPath, + processPathEnv: join(fixture.root, 'bin') + }) - const initial = await installer.getStatus() - expect(initial.state).toBe('not_installed') - expect(initial.launcherPath).toContain(join('userData', 'cli', 'bin', 'orca')) + const initial = await installer.getStatus() + expect(initial.state).toBe('not_installed') + expect(initial.launcherPath).toContain(join('userData', 'cli', 'bin', 'orca')) - const installed = await installer.install() - expect(installed.state).toBe('installed') - expect(installed.pathConfigured).toBe(true) + const installed = await installer.install() + expect(installed.state).toBe('installed') + expect(installed.pathConfigured).toBe(true) - const launcherContent = await readFile(installed.launcherPath as string, 'utf8') - expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') - expect(launcherContent).toContain(join(fixture.appPath, 'out', 'cli', 'index.js')) + const launcherContent = await readFile(installed.launcherPath as string, 'utf8') + expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') + expect(launcherContent).toContain(join(fixture.appPath, 'out', 'cli', 'index.js')) - const removed = await installer.remove() - expect(removed.state).toBe('not_installed') - }) + const removed = await installer.remove() + expect(removed.state).toBe('not_installed') + } + ) // Why: this test creates Unix symlinks and shell scripts that only apply on Linux. - it.skipIf(process.platform === 'win32')('creates a linux symlink under the requested path and warns when PATH is missing', async () => { - const fixture = await makeFixture() - const installPath = join(fixture.root, '.local', 'bin', 'orca') - const installer = new CliInstaller({ - platform: 'linux', - isPackaged: false, - userDataPath: fixture.userDataPath, - execPath: '/opt/Orca/orca', - appPath: fixture.appPath, - commandPathOverride: installPath, - processPathEnv: '/usr/bin' - }) + it.skipIf(process.platform === 'win32')( + 'creates a linux symlink under the requested path and warns when PATH is missing', + async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, '.local', 'bin', 'orca-ide') + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/opt/Orca/orca-ide', + appPath: fixture.appPath, + commandPathOverride: installPath, + processPathEnv: '/usr/bin' + }) - const installed = await installer.install() - expect(installed.state).toBe('installed') - expect(installed.pathConfigured).toBe(false) - expect(installed.detail).toContain('.local') + const installed = await installer.install() + expect(installed.state).toBe('installed') + expect(installed.commandName).toBe('orca-ide') + expect(installed.pathConfigured).toBe(false) + expect(installed.detail).toContain('.local') - const launcherContent = await readFile(installed.launcherPath as string, 'utf8') - expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') + const launcherContent = await readFile(installed.launcherPath as string, 'utf8') + expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1') - const removed = await installer.remove() - expect(removed.state).toBe('not_installed') - }) + const removed = await installer.remove() + expect(removed.state).toBe('not_installed') + } + ) + + // Why: Linux renamed the public command to avoid shadowing GNOME Orca, so + // upgrading must clean up only the old symlink owned by prior Orca installs. + it.skipIf(process.platform === 'win32')( + 'removes the old managed linux orca symlink when installing orca-ide', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const commandDir = join(homePath, '.local', 'bin') + const oldLauncherPath = join(fixture.userDataPath, 'cli', 'bin', 'orca') + const legacyCommandPath = join(commandDir, 'orca') + await mkdir(commandDir, { recursive: true }) + await mkdir(join(fixture.userDataPath, 'cli', 'bin'), { recursive: true }) + await writeFile(oldLauncherPath, '#!/usr/bin/env bash\n', 'utf8') + await symlink(oldLauncherPath, legacyCommandPath) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/opt/Orca/orca-ide', + appPath: fixture.appPath, + homePath, + processPathEnv: commandDir + }) + + const installed = await installer.install() + expect(installed.commandPath).toBe(join(commandDir, 'orca-ide')) + await expect(lstat(legacyCommandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + } + ) it('creates a windows wrapper and updates the user PATH', async () => { const fixture = await makeFixture() @@ -120,24 +158,27 @@ describe('CliInstaller', () => { }) // Why: this test creates a Unix symlink to /tmp/not-orca, which only applies on macOS/Linux. - it.skipIf(process.platform === 'win32')('reports stale when a different symlink already exists', async () => { - const fixture = await makeFixture() - const installPath = join(fixture.root, 'bin', 'orca') - await mkdir(join(fixture.root, 'bin'), { recursive: true }) - await symlink('/tmp/not-orca', installPath) + it.skipIf(process.platform === 'win32')( + 'reports stale when a different symlink already exists', + async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'bin', 'orca') + await mkdir(join(fixture.root, 'bin'), { recursive: true }) + await symlink('/tmp/not-orca', installPath) - const installer = new CliInstaller({ - platform: 'darwin', - isPackaged: false, - userDataPath: fixture.userDataPath, - execPath: '/Applications/Orca.app/Contents/MacOS/Orca', - appPath: fixture.appPath, - commandPathOverride: installPath - }) + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + appPath: fixture.appPath, + commandPathOverride: installPath + }) - await expect(installer.getStatus()).resolves.toMatchObject({ - state: 'stale', - supported: true - }) - }) + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'stale', + supported: true + }) + } + ) }) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index a3dde42ac74..054517b3742 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -10,6 +10,8 @@ import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-instal const execFileAsync = promisify(execFile) const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca' +const LINUX_COMMAND_NAME = 'orca-ide' +const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] type CliInstallerOptions = { @@ -48,6 +50,11 @@ export class CliInstaller { private readonly userPathReader: () => Promise private readonly userPathWriter: (value: string) => Promise + // Why: Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca. + private get commandName(): string { + return this.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca' + } + constructor(options: CliInstallerOptions = {}) { this.platform = options.platform ?? process.platform this.isPackaged = options.isPackaged ?? app.isPackaged @@ -73,7 +80,7 @@ export class CliInstaller { if (!spec) { return { platform: this.platform, - commandName: 'orca', + commandName: this.commandName, commandPath: null, pathDirectory: null, pathConfigured: false, @@ -91,7 +98,7 @@ export class CliInstaller { if (!launcherPath) { return { platform: this.platform, - commandName: 'orca', + commandName: this.commandName, commandPath: spec.commandPath, pathDirectory: dirname(spec.commandPath), pathConfigured: false, @@ -130,6 +137,7 @@ export class CliInstaller { // eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary. if (status.installMethod === 'symlink') { await this.installSymlink(status) + await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else { await this.installWindowsWrapper(status.commandPath, status.launcherPath) } @@ -150,6 +158,7 @@ export class CliInstaller { return status } if (status.state === 'not_installed') { + await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) if (this.platform === 'win32') { await this.removeWindowsPathEntry(dirname(status.commandPath)) return this.getStatus() @@ -165,6 +174,7 @@ export class CliInstaller { if (status.installMethod === 'symlink') { await this.removeSymlink(status.commandPath) + await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else { await unlink(status.commandPath) await this.removeWindowsPathEntry(dirname(status.commandPath)) @@ -209,7 +219,10 @@ export class CliInstaller { // Why: Linux does not have a single privileged global shell-command flow // equivalent to macOS's /usr/local/bin integration. ~/.local/bin is the // least surprising user-scoped location that many distros already expose. - return join(this.homePath, '.local', 'bin', 'orca') + // Why `orca-ide`: GNOME Orca (the screen reader) ships /usr/bin/orca on + // most Linux distros. Using `orca-ide` avoids shadowing that system + // command, matching the executableName already used for the Electron binary. + return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME) } if (this.platform === 'win32') { @@ -275,6 +288,36 @@ export class CliInstaller { } } + private async removeLegacyLinuxCommandIfManaged(launcherPath: string | null): Promise { + if (this.platform !== 'linux' || this.commandPathOverride || !launcherPath) { + return + } + + const legacyCommandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) + try { + const stats = await lstat(legacyCommandPath) + if (!stats.isSymbolicLink()) { + return + } + + const currentTarget = await readlink(legacyCommandPath) + const resolvedCurrentTarget = resolve(dirname(legacyCommandPath), currentTarget) + const legacyLauncherPath = resolve(dirname(launcherPath), LEGACY_LINUX_COMMAND_NAME) + if (resolvedCurrentTarget !== legacyLauncherPath) { + return + } + + // Why: after the Linux command rename, the old Orca-owned `orca` symlink + // would keep shadowing GNOME Orca even though the new command is installed. + await unlink(legacyCommandPath) + } catch (error) { + if (isMissingError(error)) { + return + } + throw error + } + } + private async installWindowsWrapper(commandPath: string, launcherPath: string): Promise { await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') } @@ -387,7 +430,7 @@ export class CliInstaller { }): CliInstallStatus { return { platform: this.platform, - commandName: 'orca', + commandName: this.commandName, commandPath: args.commandPath, pathDirectory: dirname(args.commandPath), pathConfigured: false, @@ -480,7 +523,7 @@ async function ensureDevLauncher(args: { const launcherPath = join( args.userDataPath, ...DEV_LAUNCHER_DIR, - args.platform === 'win32' ? 'orca.cmd' : 'orca' + args.platform === 'win32' ? 'orca.cmd' : args.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca' ) await mkdir(dirname(launcherPath), { recursive: true }) @@ -623,9 +666,12 @@ export function getBundledLauncherPath( platform: NodeJS.Platform, resourcesPath: string ): string | null { - if (platform === 'darwin' || platform === 'linux') { + if (platform === 'darwin') { return join(resourcesPath, 'bin', 'orca') } + if (platform === 'linux') { + return join(resourcesPath, 'bin', LINUX_COMMAND_NAME) + } if (platform === 'win32') { return join(resourcesPath, 'bin', 'orca.cmd') } diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index fcf877af8e1..b13d794f9b2 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { copyFile, chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { copyFile, chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,7 +12,7 @@ const itRunsUnixShell = process.platform === 'win32' ? it.skip : it const builderConfig = require('../../../config/electron-builder.config.cjs') as { asarUnpack?: string[] } -const linuxLauncherAsset = new URL('../../../resources/linux/bin/orca', import.meta.url) +const linuxLauncherAsset = new URL('../../../resources/linux/bin/orca-ide', import.meta.url) describe('packaged CLI assets', () => { it('unpacks runtime dependencies used before Electron asar integration is available', () => { @@ -26,6 +26,11 @@ describe('packaged CLI assets', () => { ) }) + itRunsUnixShell('keeps the Linux launcher executable in packaged resources', async () => { + const launcherStats = await stat(linuxLauncherAsset) + expect(launcherStats.mode & 0o111).not.toBe(0) + }) + itRunsUnixShell( 'runs the Linux launcher from its packaged path and installed symlink', async () => { @@ -35,7 +40,7 @@ describe('packaged CLI assets', () => { const resourcesDir = join(appDir, 'resources') const launcherDir = join(resourcesDir, 'bin') const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') - const launcherPath = join(launcherDir, 'orca') + const launcherPath = join(launcherDir, 'orca-ide') const electronPath = join(appDir, 'orca-ide') const cliPath = join(cliDir, 'index.js') @@ -62,7 +67,7 @@ printf 'arg=%s\\n' "$@" const homeDir = join(root, 'home') const commandDir = join(homeDir, '.local', 'bin') - const commandPath = join(commandDir, 'orca') + const commandPath = join(commandDir, 'orca-ide') await mkdir(commandDir, { recursive: true }) await mkdir(join(homeDir, 'orca'), { recursive: true }) await symlink(launcherPath, commandPath) diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index ef51b67df2b..3804251c1ba 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -20,7 +20,7 @@ function makeHostStatus(launcherPath = 'C:\\Users\\me\\AppData\\Local\\Orca\\bin } function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) { - const commandPath = '/home/alice/.local/bin/orca' + const commandPath = '/home/alice/.local/bin/orca-ide' const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' const files = new Map() if (initialFile !== null) { @@ -91,7 +91,7 @@ describe('WslCliInstaller', () => { await expect(installer.getStatus()).resolves.toMatchObject({ state: 'not_installed', - commandPath: '/home/alice/.local/bin/orca' + commandPath: '/home/alice/.local/bin/orca-ide' }) const installed = await installer.install() @@ -108,6 +108,18 @@ describe('WslCliInstaller', () => { ) ) expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript()) + const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) + expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'") + expect(installCommand).toContain('rm -f "$legacy_command_path"') + }) + + it('derives the shared WSL bridge path for current and legacy command names', () => { + expect(_internals.getBridgePathFromCommandPath('/home/alice/.local/bin/orca-ide')).toBe( + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ) + expect(_internals.getBridgePathFromCommandPath('/home/alice/.local/bin/orca')).toBe( + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ) }) it('reports installed WSL launchers whose bin directory is missing from PATH', async () => { diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 257ea5db3ae..33eedeb3e5f 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -21,6 +21,8 @@ import { const execFileAsync = promisify(execFile) const MANAGED_MARKER = getWslLauncherMarker() const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() +const WSL_COMMAND_NAME = 'orca-ide' +const LEGACY_WSL_COMMAND_NAME = 'orca' type WslCliInstallerOptions = { platform?: NodeJS.Platform @@ -137,6 +139,9 @@ export class WslCliInstaller { `mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`, `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, `bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, + `legacy_command_path=${quoteShell( + `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` + )}`, 'bridge_tmp="${bridge_path}.tmp.$$"', 'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }', 'trap cleanup EXIT', @@ -158,6 +163,9 @@ export class WslCliInstaller { getBridgePathFromCommandPath(status.commandPath), BRIDGE_MANAGED_MARKER ), + // Why: the command was renamed to avoid GNOME Orca; remove only the + // old Orca-managed WSL wrapper so unmanaged `orca` commands survive. + `if [ -f "$legacy_command_path" ] && grep -Fq ${quoteShell(MANAGED_MARKER)} "$legacy_command_path"; then rm -f "$legacy_command_path"; fi`, `mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, 'trap - EXIT' @@ -240,7 +248,8 @@ export class WslCliInstaller { } const pathDirectory = `${home}/.local/bin` - const commandPath = `${pathDirectory}/orca` + // Why: matches the Linux CLI rename to `orca-ide` (avoids GNOME Orca conflict). + const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}` const pathConfigured = ( await this.run( @@ -296,9 +305,9 @@ export class WslCliInstaller { }): CliInstallStatus { return { platform: 'linux', - commandName: 'orca', + commandName: WSL_COMMAND_NAME, commandPath: args.commandPath, - pathDirectory: args.commandPath.replace(/\/orca$/, ''), + pathDirectory: getPosixDirname(args.commandPath), pathConfigured: args.pathConfigured, launcherPath: args.launcherPath, installMethod: 'wrapper', @@ -308,7 +317,7 @@ export class WslCliInstaller { unsupportedReason: null, detail: args.state === 'installed' && !args.pathConfigured - ? `${args.commandPath} is registered, but ${args.commandPath.replace(/\/orca$/, '')} is not on PATH in ${args.distro}.` + ? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.` : args.detail } } @@ -319,7 +328,7 @@ export class WslCliInstaller { ): CliInstallStatus { return { platform: 'linux', - commandName: 'orca', + commandName: WSL_COMMAND_NAME, commandPath: null, pathDirectory: null, pathConfigured: false, @@ -360,5 +369,6 @@ function buildEncodedWslBashCommand(command: string): string { export const _internals = { buildEncodedWslBashCommand, buildWslBridgeScript, - buildWslLauncher + buildWslLauncher, + getBridgePathFromCommandPath } diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 0a74e9c51fe..23f8f27ce65 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -44,7 +44,9 @@ try { } export function getBridgePathFromCommandPath(commandPath: string): string { - return `${commandPath.replace(/\/\.local\/bin\/orca$/, '/.local/share/orca')}/orca-wsl-bridge.ps1` + // Why: both the current Linux command and the legacy pre-rename command + // share one WSL bridge under ~/.local/share/orca. + return `${commandPath.replace(/\/\.local\/bin\/(?:orca|orca-ide)$/, '/.local/share/orca')}/orca-wsl-bridge.ps1` } export function buildSafeReplaceGuard(path: string, managedMarker: string): string { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index c59b6f1624d..cb0d0084726 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -74,6 +74,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalAllowOsc52Clipboard: false, setupScriptLaunchMode: 'split-vertical', terminalScrollbackBytes: 10_000_000, + localAccountRuntime: 'host', + localAccountWslDistro: null, openLinksInApp: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', @@ -479,7 +481,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) @@ -517,7 +521,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) expect(warnSpy).toHaveBeenCalled() }) @@ -533,7 +539,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) }) @@ -673,6 +681,651 @@ describe('CodexRuntimeHomeService', () => { ) }) + it('does not touch host auth on startup when the active account is WSL-backed', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"host-system"}\n', 'utf-8') + const wslManagedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"wsl"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + const store = createStore(settings) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( + '{"account":"wsl"}\n' + ) + expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('clears a selected WSL managed account when auth.json is missing', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-token') + ) + rmSync(join(managedHomePath, 'auth.json'), { force: true }) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const firstAuth = createCodexAuthJson('first@example.com', 'acct-first', 'first-token') + const secondAuth = createCodexAuthJson('second@example.com', 'acct-second', 'second-token') + const firstManagedHomePath = createManagedAuth(testState.userDataDir, 'account-1', firstAuth) + const secondManagedHomePath = createManagedAuth(testState.userDataDir, 'account-2', secondAuth) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'first@example.com', + managedHomePath: firstManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-first', + workspaceLabel: null, + workspaceAccountId: 'acct-first', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'second@example.com', + managedHomePath: secondManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-2/home', + providerAccountId: 'acct-second', + workspaceLabel: null, + workspaceAccountId: 'acct-second', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + + store.updateSettings({ + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } + }) + service.syncForCurrentSelection(target) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not use host auth baseline to accept stale WSL runtime auth', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') + const wslManagedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'managed-newer', + 2_000 + ) + const staleWslRuntimeAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-stale', + 1_000 + ) + const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) + const wslManagedHomePath = createManagedAuth( + testState.userDataDir, + 'wsl-account', + wslManagedAuth + ) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + mkdirSync(wslRuntimeHomePath, { recursive: true }) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), staleWslRuntimeAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedHomePath: hostManagedHomePath, + providerAccountId: 'acct-host', + workspaceLabel: null, + workspaceAccountId: 'acct-host', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'host-account', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(hostAuth) + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) + const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) + const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') + + service.clearLastWrittenAuthJson('account-1') + service.syncForCurrentSelection(target) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + ) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('uses the default distro selection for WSL-default rate-limit fetches', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const ubuntuAuth = createCodexAuthJson('ubuntu@example.com', 'acct-ubuntu', 'ubuntu-token') + const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') + const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) + const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) + const runtimeAuthPath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home', + 'auth.json' + ) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'ubuntu-account', + email: 'ubuntu@example.com', + managedHomePath: ubuntuHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/ubuntu/home', + providerAccountId: 'acct-ubuntu', + workspaceLabel: null, + workspaceAccountId: 'acct-ubuntu', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'debian-account', + email: 'debian@example.com', + managedHomePath: debianHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/debian/home', + providerAccountId: 'acct-debian', + workspaceLabel: null, + workspaceAccountId: 'acct-debian', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account', Debian: 'debian-account' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toBe( + join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + ) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not write WSL system-default auth into managed accounts', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-old', 1_000) + const systemDefaultAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'system-newer', + 2_000 + ) + const managedHomePath = createManagedAuth(testState.userDataDir, 'wsl-account', managedAuth) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemDefaultAuth, 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('reads WSL system-default token refreshes back to WSL system auth', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'system-old', 1_000) + const refreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-refreshed', + 2_000 + ) + const systemCodexHomePath = join(wslHome, '.codex') + mkdirSync(systemCodexHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('preserves WSL system-default token refreshes after app restart', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const systemAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'system-old', 1_000) + const refreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-refreshed', + 2_000 + ) + const systemCodexHomePath = join(wslHome, '.codex') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + mkdirSync(systemCodexHomePath, { recursive: true }) + mkdirSync(wslRuntimeHomePath, { recursive: true }) + writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('does not overwrite auth.json when no managed account was ever active', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(runtimeAuthPath, '{"account":"original"}\n', 'utf-8') @@ -998,7 +1651,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 981e7aec70c..0be2ebef6e6 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -12,7 +12,7 @@ import { rmSync, statSync } from 'node:fs' -import { dirname, extname, join, parse, relative } from 'node:path' +import { dirname, extname, join, parse, relative, win32 as pathWin32 } from 'node:path' import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' @@ -24,6 +24,14 @@ import { } from '../codex/codex-home-paths' import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + getSelectedCodexAccountIdForTarget, + normalizeCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { getDefaultWslDistro, getWslHome } from '../wsl' type CodexAuthIdentity = { email: string | null @@ -67,6 +75,11 @@ export class CodexRuntimeHomeService { // login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as // the new system default instead of restoring a stale snapshot. private lastWrittenAuthJson: string | null = null + // Why: WSL terminals have their own stable runtime homes per distro. They + // cannot share the host baseline or host sync can make stale WSL auth look + // newer than managed storage. + private readonly lastWrittenWslAuthJsonByDistro = new Map() + private readonly lastSyncedWslAccountIdByDistro = new Map() private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { @@ -77,10 +90,26 @@ export class CodexRuntimeHomeService { private initializeLastSyncedState(): void { const settings = this.store.getSettings() - this.lastSyncedAccountId = settings.activeCodexManagedAccountId + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + // Why: WSL-managed homes are never materialized into host ~/.codex. + // Treating one as "last synced" makes cold start look like a host-account + // transition and can restore/delete host auth that Orca never touched. + this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) + ? null + : normalizeCodexRuntimeSelection(settings).host } - prepareForCodexLaunch(): string { + prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return ( + this.syncWslRuntimeForCurrentSelection(wslTarget) ?? + this.getWslSystemCodexHomePath(wslTarget) + ) + } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() @@ -88,14 +117,38 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } - prepareForRateLimitFetch(): string { + private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return null + } + const home = getWslHome(distro) + return home ? this.joinWslPath(home, '.codex') : null + } + + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return ( + this.syncWslRuntimeForCurrentSelection(wslTarget) ?? + this.getWslSystemCodexHomePath(wslTarget) + ) + } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() return this.getRuntimeHomePath() } - syncForCurrentSelection(): void { + syncForCurrentSelection(target?: CodexAccountSelectionTarget): void { + if (target?.runtime === 'wsl') { + this.syncWslRuntimeForCurrentSelection(target) + return + } + const settings = this.store.getSettings() const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) if (this.lastSyncedAccountId === null) { @@ -103,12 +156,29 @@ export class CodexRuntimeHomeService { } const activeAccount = this.getActiveAccount( settings.codexManagedAccounts, - settings.activeCodexManagedAccountId + normalizeCodexRuntimeSelection(settings).host ) const previousAccount = this.getActiveAccount( settings.codexManagedAccounts, this.lastSyncedAccountId ) + if (this.getWslManagedHomePath(activeAccount)) { + const previousWasHostManaged = previousAccount && !this.getWslManagedHomePath(previousAccount) + const outgoingReadBackResult = previousWasHostManaged + ? this.readBackRefreshedTokensForAccount(previousAccount, { + updateLastWrittenAuthJson: false + }) + : 'unchanged' + if (previousWasHostManaged) { + this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult !== 'rejected' + }) + } + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + this.skipNextReadBackForAccountId = null + return + } let outgoingReadBackResult: CodexReadBackResult = 'unchanged' if (previousAccount && previousAccount.id !== activeAccount?.id) { outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, { @@ -116,8 +186,14 @@ export class CodexRuntimeHomeService { }) } if (!activeAccount) { - if (settings.activeCodexManagedAccountId) { - this.store.updateSettings({ activeCodexManagedAccountId: null }) + if (normalizeCodexRuntimeSelection(settings).host) { + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) } // Why: only restore the system-default mirror when transitioning FROM a // managed account. When no managed account was ever active, later syncs @@ -164,7 +240,13 @@ export class CodexRuntimeHomeService { console.warn( '[codex-runtime-home] Active managed account is missing auth.json, restoring system default' ) - this.store.updateSettings({ activeCodexManagedAccountId: null }) + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) if (this.lastSyncedAccountId !== null) { this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null @@ -201,8 +283,10 @@ export class CodexRuntimeHomeService { // re-auth or add-account. Those flows write fresh tokens to managed storage, // so the read-back must be skipped to avoid overwriting them with stale // runtime tokens. - clearLastWrittenAuthJson(accountId = this.store.getSettings().activeCodexManagedAccountId): void { - if (accountId === this.store.getSettings().activeCodexManagedAccountId) { + clearLastWrittenAuthJson( + accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host + ): void { + if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } this.skipNextReadBackForAccountId = accountId @@ -211,18 +295,36 @@ export class CodexRuntimeHomeService { private readBackRefreshedTokens(options: { updateLastWrittenAuthJson: boolean }): CodexReadBackResult { + return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options) + } + + private readBackRefreshedTokensFromPath( + runtimeAuthPath: string, + options: { + updateLastWrittenAuthJson: boolean + lastWrittenAuthJson?: string | null + setLastWrittenAuthJson?: (contents: string) => void + expectedAccountId?: string + } + ): CodexReadBackResult { try { - const runtimeAuthPath = this.getRuntimeAuthPath() if (!existsSync(runtimeAuthPath)) { return 'unchanged' } + const lastWrittenAuthJson = + options.lastWrittenAuthJson === undefined + ? this.lastWrittenAuthJson + : options.lastWrittenAuthJson const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (this.lastWrittenAuthJson !== null && runtimeContents === this.lastWrittenAuthJson) { + if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { return 'unchanged' } - const match = this.findManagedAccountForRuntimeAuth(runtimeContents) + const match = this.findManagedAccountForRuntimeAuth( + runtimeContents, + options.expectedAccountId + ) if (match.kind !== 'matched') { if (match.kind === 'ambiguous') { console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') @@ -232,7 +334,7 @@ export class CodexRuntimeHomeService { // Why: after app restart, Orca has no last-written baseline. Identity // alone cannot prove runtime auth is newer than managed storage. if ( - this.lastWrittenAuthJson === null && + lastWrittenAuthJson === null && !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) ) { return 'rejected' @@ -240,7 +342,11 @@ export class CodexRuntimeHomeService { writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) if (options.updateLastWrittenAuthJson) { - this.lastWrittenAuthJson = runtimeContents + if (options.setLastWrittenAuthJson) { + options.setLastWrittenAuthJson(runtimeContents) + } else { + this.lastWrittenAuthJson = runtimeContents + } } return 'persisted' } catch (error) { @@ -253,10 +359,13 @@ export class CodexRuntimeHomeService { } private readBackRefreshedTokensForAccount( - _account: CodexManagedAccount, + account: CodexManagedAccount, options: { updateLastWrittenAuthJson: boolean } ): CodexReadBackResult { - return this.readBackRefreshedTokens(options) + return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), { + ...options, + expectedAccountId: account.id + }) } private safeSyncForCurrentSelection(): void { @@ -277,13 +386,184 @@ export class CodexRuntimeHomeService { return accounts.find((account) => account.id === activeAccountId) ?? null } - private findManagedAccountForRuntimeAuth(runtimeAuthContents: string): CodexReadBackMatch { + private getWslManagedHomePath(account: CodexManagedAccount | null): string | null { + if (!account) { + return null + } + if (account.managedHomeRuntime === 'wsl' && parseWslUncPath(account.managedHomePath)) { + return account.managedHomePath + } + return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null + } + + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + + const wslTarget = this.resolveWslDefaultTarget(target) + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, wslTarget) + ) + const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() + if (!distro) { + return null + } + + const runtimeHomePath = this.getWslRuntimeHomePath(distro) + if (!runtimeHomePath) { + return null + } + + mkdirSync(runtimeHomePath, { recursive: true }) + this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) + + const runtimeAuthPath = join(runtimeHomePath, 'auth.json') + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null + if (previousWslAccountId) { + if (this.skipNextReadBackForAccountId === previousWslAccountId) { + this.skipNextReadBackForAccountId = null + } else { + const previousWslAccount = this.getActiveAccount( + settings.codexManagedAccounts, + previousWslAccountId + ) + if (previousWslAccount) { + this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: previousWslAccount.id + }) + } + } + } + + const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null + if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { + const activeAuth = readFileSync(activeAuthPath, 'utf-8') + this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) + return runtimeHomePath + } + if (activeAccount && activeAuthPath) { + console.warn( + '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' + ) + this.store.updateSettings({ + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + null, + wslTarget + ) + }) + } + + const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) + if (systemAuthPath && existsSync(systemAuthPath)) { + const systemAuth = readFileSync(systemAuthPath, 'utf-8') + const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null + const runtimeAuth = existsSync(runtimeAuthPath) + ? readFileSync(runtimeAuthPath, 'utf-8') + : null + if ( + runtimeAuth !== null && + runtimeAuth !== systemAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && + ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || + (mirroredSystemDefaultAuth === null && + this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) + ) { + // Why: WSL runtime homes are per-distro and their in-memory baseline is + // lost on app restart. A same-identity fresher runtime auth is a Codex + // token refresh and should be copied back before we mirror ~/.codex. + this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenWslAuthJsonByDistro.set(distro, null) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + private getWslRuntimeHomePath(distro: string): string | null { + const home = getWslHome(distro) + return home + ? this.joinWslPath(home, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + : null + } + + private joinWslPath(basePath: string, ...segments: string[]): string { + return parseWslUncPath(basePath) + ? pathWin32.join(basePath, ...segments) + : join(basePath, ...segments) + } + + private resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget { + if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } + + private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { + const home = this.getWslSystemCodexHomePath(target) + return home ? this.joinWslPath(home, 'auth.json') : null + } + + private seedWslRuntimeHome( + runtimeHomePath: string, + activeAccount: CodexManagedAccount | null, + distro: string + ): void { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + if (existsSync(runtimeConfigPath)) { + return + } + + const candidateHomes = [ + activeAccount?.managedHomePath, + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + ].filter((value): value is string => Boolean(value)) + for (const homePath of candidateHomes) { + const configPath = join(homePath, 'config.toml') + if (existsSync(configPath)) { + copyFileSync(configPath, runtimeConfigPath) + return + } + } + } + + private findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string + ): CodexReadBackMatch { const matches: { account: CodexManagedAccount managedAuthPath: string managedAuthContents: string }[] = [] for (const account of this.store.getSettings().codexManagedAccounts) { + if (expectedAccountId && account.id !== expectedAccountId) { + continue + } const managedAuthPath = join(account.managedHomePath, 'auth.json') if (!existsSync(managedAuthPath)) { continue @@ -875,6 +1155,15 @@ export class CodexRuntimeHomeService { this.lastWrittenAuthJson = contents } + private writeRuntimeAuthAtPath(authPath: string, contents: string): void { + if (this.fileContentsEqual(authPath, contents)) { + this.ensureOwnerOnlyMode(authPath) + return + } + mkdirSync(dirname(authPath), { recursive: true }) + writeFileAtomically(authPath, contents, { mode: 0o600 }) + } + private fileContentsEqual(targetPath: string, contents: string): boolean { try { return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents diff --git a/src/main/codex-accounts/runtime-selection.test.ts b/src/main/codex-accounts/runtime-selection.test.ts new file mode 100644 index 00000000000..df98d20242b --- /dev/null +++ b/src/main/codex-accounts/runtime-selection.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' +import { + getSelectedCodexAccountIdForTarget, + pruneInvalidCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget +} from './runtime-selection' + +function createSettings( + overrides: Partial< + Pick + > = {} +): Pick { + return { + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, + ...overrides + } +} + +function createAccount( + overrides: Partial & Pick +): CodexManagedAccount { + const { id, ...rest } = overrides + return { + id, + email: `${id}@example.com`, + managedHomePath: `/tmp/${id}`, + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...rest + } +} + +describe('Codex runtime account selection', () => { + it('selects host and WSL accounts independently', () => { + const first = setSelectedCodexAccountIdForTarget({ host: null, wsl: {} }, 'host-account', { + runtime: 'host' + }) + const next = setSelectedCodexAccountIdForTarget(first, 'wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + + it('resolves a WSL default target when exactly one WSL distro has a selection', () => { + const settings = createSettings({ + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + } + }) + + expect(getSelectedCodexAccountIdForTarget(settings, { runtime: 'wsl' })).toBe('wsl-account') + expect(getSelectedCodexAccountIdForTarget(settings, { runtime: 'host' })).toBe('host-account') + }) + + it('clears WSL selections without clearing the host selection for a WSL default target', () => { + const next = setSelectedCodexAccountIdForTarget( + { + host: 'host-account', + wsl: { Ubuntu: 'wsl-account', Debian: 'other-wsl-account' } + }, + null, + { runtime: 'wsl' } + ) + + expect(next).toEqual({ + host: 'host-account', + wsl: { Ubuntu: null, Debian: null } + }) + }) + + it('drops selections whose account belongs to another runtime', () => { + const selection = pruneInvalidCodexRuntimeSelection( + { + host: 'wsl-account', + wsl: { Ubuntu: 'host-account', Debian: 'missing-account' } + }, + [ + createAccount({ id: 'host-account' }), + createAccount({ + id: 'wsl-account', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + ] + ) + + expect(selection).toEqual({ + host: null, + wsl: { Ubuntu: null, Debian: null } + }) + }) +}) diff --git a/src/main/codex-accounts/runtime-selection.ts b/src/main/codex-accounts/runtime-selection.ts new file mode 100644 index 00000000000..4b6ff3a5171 --- /dev/null +++ b/src/main/codex-accounts/runtime-selection.ts @@ -0,0 +1,146 @@ +import type { + CodexManagedAccount, + CodexManagedAccountRuntimeSelection, + GlobalSettings +} from '../../shared/types' + +export type CodexAccountSelectionTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type NormalizedCodexAccountSelectionTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +export function normalizeCodexAccountSelectionTarget( + target?: CodexAccountSelectionTarget | null +): NormalizedCodexAccountSelectionTarget { + if (target?.runtime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: normalizeWslDistro(target.wslDistro) + } + } + return { runtime: 'host', wslDistro: null } +} + +export function normalizeCodexRuntimeSelection( + settings: Pick< + GlobalSettings, + 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime' + > +): CodexManagedAccountRuntimeSelection { + return { + host: + settings.activeCodexManagedAccountIdsByRuntime?.host ?? + settings.activeCodexManagedAccountId ?? + null, + wsl: { ...settings.activeCodexManagedAccountIdsByRuntime?.wsl } + } +} + +export function getSelectedCodexAccountIdForTarget( + settings: Pick< + GlobalSettings, + 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime' + >, + target?: CodexAccountSelectionTarget | null +): string | null { + const selection = normalizeCodexRuntimeSelection(settings) + const normalizedTarget = normalizeCodexAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return selection.host + } + if (normalizedTarget.wslDistro) { + return selection.wsl[getWslSelectionKey(normalizedTarget.wslDistro)] ?? null + } + const selectedIds = Array.from(new Set(Object.values(selection.wsl).filter(Boolean))) + return ( + selection.wsl[getWslSelectionKey(null)] ?? (selectedIds.length === 1 ? selectedIds[0] : null) + ) +} + +export function setSelectedCodexAccountIdForTarget( + selection: CodexManagedAccountRuntimeSelection, + accountId: string | null, + target?: CodexAccountSelectionTarget | null +): CodexManagedAccountRuntimeSelection { + const normalizedTarget = normalizeCodexAccountSelectionTarget(target) + if (normalizedTarget.runtime === 'host') { + return { host: accountId, wsl: { ...selection.wsl } } + } + if (accountId === null && normalizedTarget.wslDistro === null) { + return { + host: selection.host, + wsl: Object.fromEntries(Object.keys(selection.wsl).map((key) => [key, null])) + } + } + return { + host: selection.host, + wsl: { + ...selection.wsl, + [getWslSelectionKey(normalizedTarget.wslDistro)]: accountId + } + } +} + +export function removeCodexAccountIdFromSelection( + selection: CodexManagedAccountRuntimeSelection, + accountId: string +): CodexManagedAccountRuntimeSelection { + const nextWsl: Record = {} + for (const [distro, selectedId] of Object.entries(selection.wsl)) { + nextWsl[distro] = selectedId === accountId ? null : selectedId + } + return { + host: selection.host === accountId ? null : selection.host, + wsl: nextWsl + } +} + +export function pruneInvalidCodexRuntimeSelection( + selection: CodexManagedAccountRuntimeSelection, + accounts: CodexManagedAccount[] +): CodexManagedAccountRuntimeSelection { + const hostAccount = selection.host + ? accounts.find((account) => account.id === selection.host) + : null + const nextWsl: Record = {} + for (const [distroKey, accountId] of Object.entries(selection.wsl)) { + if (!accountId) { + nextWsl[distroKey] = null + continue + } + const account = accounts.find((entry) => entry.id === accountId) + nextWsl[distroKey] = + account && + account.managedHomeRuntime === 'wsl' && + getWslSelectionKey(account.wslDistro) === distroKey + ? accountId + : null + } + return { + host: hostAccount && hostAccount.managedHomeRuntime !== 'wsl' ? selection.host : null, + wsl: nextWsl + } +} + +export function getCodexSelectionTargetForAccount( + account: CodexManagedAccount +): CodexAccountSelectionTarget { + if (account.managedHomeRuntime === 'wsl') { + return { runtime: 'wsl', wslDistro: account.wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getWslSelectionKey(wslDistro: string | null | undefined): string { + return normalizeWslDistro(wslDistro) ?? '__default__' +} + +function normalizeWslDistro(wslDistro: string | null | undefined): string | null { + const trimmed = wslDistro?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 52ef01a220e..34b712c0131 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -23,6 +23,11 @@ vi.mock('node:os', async () => { } }) +function decodeEncodedWslBashCommand(command: string): string { + const encoded = command.match(/^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/)?.[1] + return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command +} + function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true @@ -61,6 +66,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalAllowOsc52Clipboard: false, setupScriptLaunchMode: 'split-vertical', terminalScrollbackBytes: 10_000_000, + localAccountRuntime: 'host', + localAccountWslDistro: null, openLinksInApp: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', @@ -395,6 +402,7 @@ describe('CodexAccountService config sync', () => { ) vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), spawn: spawnMock })) vi.doMock('../codex-cli/command', () => ({ @@ -419,6 +427,314 @@ describe('CodexAccountService config sync', () => { expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) + it('adds a managed Codex account inside WSL when the account context is WSL', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home') + const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home' + writeFileSync(wslConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8') + + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + expect(args.slice(0, 2)).toEqual(['-d', 'Debian']) + if (script.includes('WSL_DISTRO_NAME')) { + return 'Debian\n/home/alice\n' + } + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') + return '' + }) + const spawnMock = vi.fn((command: string, args: string[]) => { + expect(command).toBe('wsl.exe') + expect(args).toEqual([ + '-d', + 'Debian', + '--', + 'bash', + '-lc', + `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` + ]) + expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( + 'sandbox_mode = "danger-full-access"\n' + ) + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + + const payload = Buffer.from(JSON.stringify({ email: 'wsl@example.com' })).toString( + 'base64url' + ) + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ tokens: { id_token: `header.${payload}.signature` } }), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) + + vi.doMock('node:crypto', () => ({ + randomUUID: () => 'account-id-for-test' + })) + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Debian', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: (linuxPath: string) => + linuxPath.endsWith('/.codex/config.toml') ? wslConfigPath : wslManagedHomePath + })) + + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.addAccount({ runtime: 'wsl', wslDistro: 'Debian' }) + + expect(result.accounts[0]).toMatchObject({ + email: 'wsl@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Debian' + }) + expect(store.getSettings().codexManagedAccounts[0]).toMatchObject({ + managedHomePath: wslManagedHomePath, + wslLinuxHomePath, + managedHomeRuntime: 'wsl' + }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('reauthenticates a WSL managed Codex account inside its distro', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'old@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + + const execFileSyncMock = vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + return '' + }) + const spawnMock = vi.fn((command: string, args: string[]) => { + expect(command).toBe('wsl.exe') + expect(args).toEqual([ + '-d', + 'Ubuntu', + '--', + 'bash', + '-lc', + `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` + ]) + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + writeFileSync( + join(wslManagedHomePath, 'auth.json'), + JSON.stringify({ + tokens: { + id_token: `header.${Buffer.from(JSON.stringify({ email: 'new@example.com' })).toString( + 'base64url' + )}.signature` + } + }), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) + + vi.doMock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawn: spawnMock + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-1') + + expect(result.accounts[0]).toMatchObject({ + email: 'new@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('removes a WSL managed account only after canonical path validation', async () => { + vi.resetModules() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home') + const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + mkdirSync(wslManagedHomePath, { recursive: true }) + writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8') + + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn((_command: string, args: string[]) => { + const script = decodeEncodedWslBashCommand(String(args.at(-1))) + if (script.includes('readlink -f')) { + return `${wslLinuxHomePath}\n` + } + return '' + }), + spawn: vi.fn() + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (path: string) => + path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null + })) + vi.doMock('../wsl', () => ({ + toWindowsWslPath: () => wslManagedHomePath + })) + + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.removeAccount('account-1') + + expect(result.accounts).toHaveLength(0) + expect(existsSync(wslManagedHomePath)).toBe(false) + expect(existsSync(join(testState.userDataDir, 'wsl-account'))).toBe(false) + expect(rateLimits.evictInactiveCodexCache).toHaveBeenCalledWith('account-1') + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('deselects active account via selectAccount(null)', async () => { const managedHomePath = createManagedHome( testState.userDataDir, @@ -460,6 +776,80 @@ describe('CodexAccountService config sync', () => { expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalled() }) + it('keeps Windows and WSL active Codex account selections separate', async () => { + const hostManagedHomePath = createManagedHome( + testState.userDataDir, + 'host-account', + '', + '{"account":"host"}\n' + ) + const wslManagedHomePath = + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-accounts\\wsl-account\\home' + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedHomePath: hostManagedHomePath, + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'host-account', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account', + wsl: {} + } + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.selectAccountForTarget('wsl-account', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(result.activeAccountId).toBe('host-account') + expect(result.activeAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + expect(store.getSettings().activeCodexManagedAccountId).toBe('host-account') + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime).toEqual({ + host: 'host-account', + wsl: { Ubuntu: 'wsl-account' } + }) + }) + it('removes an account and cleans up managed home', async () => { const managedHomePath = createManagedHome( testState.userDataDir, diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 8b6306e24ff..ce6414ddf0e 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -2,9 +2,9 @@ account lifecycle, path safety, login, and identity parsing in one audited main-process module so the managed-account boundary stays explicit. */ import { randomUUID } from 'node:crypto' -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' @@ -18,6 +18,19 @@ import { writeFileAtomically } from './fs-utils' import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + getCodexSelectionTargetForAccount, + getSelectedCodexAccountIdForTarget, + normalizeCodexAccountSelectionTarget, + normalizeCodexRuntimeSelection, + pruneInvalidCodexRuntimeSelection, + removeCodexAccountIdFromSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -34,6 +47,22 @@ type ResolvedCodexIdentity = { workspaceAccountId: string | null } +export type CodexAccountAddTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +type ManagedHomeLocation = { + managedHomePath: string + managedHomeRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxHomePath: string | null +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + export class CodexAccountService { // Why: account mutations read settings, do async work (login, rate-limit // refresh), then write settings. Without serialization, overlapping calls @@ -59,8 +88,8 @@ export class CodexAccountService { return this.getSnapshot() } - async addAccount(): Promise { - return this.serializeMutation(() => this.doAddAccount()) + async addAccount(target?: CodexAccountAddTarget): Promise { + return this.serializeMutation(() => this.doAddAccount(target)) } async reauthenticateAccount(accountId: string): Promise { @@ -75,9 +104,17 @@ export class CodexAccountService { return this.serializeMutation(() => this.doSelectAccount(accountId)) } - private async doAddAccount(): Promise { + async selectAccountForTarget( + accountId: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + return this.serializeMutation(() => this.doSelectAccount(accountId, target)) + } + + private async doAddAccount(target?: CodexAccountAddTarget): Promise { const accountId = randomUUID() - const managedHomePath = this.createManagedHome(accountId) + const managedHome = this.createManagedHome(accountId, target) + const { managedHomePath } = managedHome try { this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath) @@ -92,6 +129,9 @@ export class CodexAccountService { id: accountId, email: identity.email, managedHomePath, + managedHomeRuntime: managedHome.managedHomeRuntime, + wslDistro: managedHome.wslDistro, + wslLinuxHomePath: managedHome.wslLinuxHomePath, providerAccountId: identity.providerAccountId, workspaceLabel: identity.workspaceLabel, workspaceAccountId: identity.workspaceAccountId, @@ -101,9 +141,17 @@ export class CodexAccountService { } const settings = this.store.getSettings() + const selection = normalizeCodexRuntimeSelection(settings) + const targetSelection = getCodexSelectionTargetForAccount(account) this.store.updateSettings({ codexManagedAccounts: [...settings.codexManagedAccounts, account], - activeCodexManagedAccountId: account.id + activeCodexManagedAccountId: + targetSelection.runtime === 'host' ? account.id : selection.host, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + selection, + account.id, + targetSelection + ) }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(account.id) @@ -111,8 +159,8 @@ export class CodexAccountService { // Why: the new account becomes active, so the previous active account is // now inactive and its last-known usage should be cached for the switcher. - const outgoingAccountId = settings.activeCodexManagedAccountId - await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) + const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, targetSelection) return this.getSnapshot() } catch (error) { this.safeRemoveManagedHome(managedHomePath) @@ -151,12 +199,15 @@ export class CodexAccountService { }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(accountId) - this.runtimeHome.syncForCurrentSelection() + this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account)) // Why: re-auth can change which actual Codex identity the managed home // points at. Force a fresh read immediately so the status bar cannot keep // showing the previous account's quota under the updated label. - await this.rateLimits.refreshForCodexAccountChange() + await this.rateLimits.refreshForCodexAccountChange( + undefined, + getCodexSelectionTargetForAccount(account) + ) return this.getSnapshot() } @@ -164,14 +215,17 @@ export class CodexAccountService { const account = this.requireAccount(accountId) const settings = this.store.getSettings() const nextAccounts = settings.codexManagedAccounts.filter((entry) => entry.id !== accountId) + const nextSelection = removeCodexAccountIdFromSelection( + normalizeCodexRuntimeSelection(settings), + accountId + ) const nextActiveId = - settings.activeCodexManagedAccountId === accountId - ? null - : settings.activeCodexManagedAccountId + settings.activeCodexManagedAccountId === accountId ? null : nextSelection.host this.store.updateSettings({ codexManagedAccounts: nextAccounts, - activeCodexManagedAccountId: nextActiveId + activeCodexManagedAccountId: nextActiveId, + activeCodexManagedAccountIdsByRuntime: nextSelection }) this.runtimeHome.syncForCurrentSelection() @@ -180,28 +234,49 @@ export class CodexAccountService { // so purge its cached usage to avoid stale entries. this.rateLimits.evictInactiveCodexCache(accountId) await this.rateLimits.refreshForCodexAccountChange( - settings.activeCodexManagedAccountId === accountId - ? settings.activeCodexManagedAccountId - : undefined + getSelectedCodexAccountIdForTarget(settings, getCodexSelectionTargetForAccount(account)) === + accountId + ? accountId + : undefined, + getCodexSelectionTargetForAccount(account) ) return this.getSnapshot() } - private async doSelectAccount(accountId: string | null): Promise { + private async doSelectAccount( + accountId: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + let effectiveTarget = target if (accountId !== null) { - this.requireAccount(accountId) + const account = this.requireAccount(accountId) + const accountTarget = getCodexSelectionTargetForAccount(account) + const requestedTarget = normalizeCodexAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeCodexAccountSelectionTarget(accountTarget) + if ( + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + ) { + throw new Error('That Codex account belongs to a different runtime.') + } + effectiveTarget = accountTarget } const previousSettings = this.store.getSettings() - const outgoingAccountId = previousSettings.activeCodexManagedAccountId + const selection = normalizeCodexRuntimeSelection(previousSettings) + const outgoingAccountId = getSelectedCodexAccountIdForTarget(previousSettings, effectiveTarget) + const nextSelection = setSelectedCodexAccountIdForTarget(selection, accountId, effectiveTarget) this.store.updateSettings({ - activeCodexManagedAccountId: accountId + activeCodexManagedAccountId: + effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, + activeCodexManagedAccountIdsByRuntime: nextSelection }) this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.syncForCurrentSelection() + this.runtimeHome.syncForCurrentSelection(effectiveTarget) - await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() } @@ -211,7 +286,8 @@ export class CodexAccountService { accounts: settings.codexManagedAccounts .map((account) => this.toSummary(account)) .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: settings.activeCodexManagedAccountId + activeAccountId: normalizeCodexRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings) } } @@ -219,6 +295,8 @@ export class CodexAccountService { return { id: account.id, email: account.email, + managedHomeRuntime: account.managedHomeRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, providerAccountId: account.providerAccountId ?? null, workspaceLabel: account.workspaceLabel ?? null, workspaceAccountId: account.workspaceAccountId ?? null, @@ -239,25 +317,99 @@ export class CodexAccountService { private normalizeActiveSelection(): void { const settings = this.store.getSettings() - if (!settings.activeCodexManagedAccountId) { - return - } - const hasActiveAccount = settings.codexManagedAccounts.some( - (entry) => entry.id === settings.activeCodexManagedAccountId + const selection = normalizeCodexRuntimeSelection(settings) + const nextSelection = pruneInvalidCodexRuntimeSelection( + selection, + settings.codexManagedAccounts ) - if (!hasActiveAccount) { - this.store.updateSettings({ activeCodexManagedAccountId: null }) + const changed = + nextSelection.host !== selection.host || + JSON.stringify(nextSelection.wsl) !== JSON.stringify(selection.wsl) + if (changed) { + this.store.updateSettings({ + activeCodexManagedAccountId: nextSelection.host, + activeCodexManagedAccountIdsByRuntime: nextSelection + }) } } - private createManagedHome(accountId: string): string { + private createManagedHome( + accountId: string, + target?: CodexAccountAddTarget + ): ManagedHomeLocation { + const wslHome = this.tryCreateWslManagedHome(accountId, target) + if (wslHome) { + return wslHome + } + const managedHomePath = join(this.getManagedAccountsRoot(), accountId, 'home') mkdirSync(managedHomePath, { recursive: true }) // Why: Codex expects CODEX_HOME to be a concrete directory it can own. We // pre-create the directory and leave a marker so future cleanup code can // prove the path belongs to Orca before deleting anything. writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8') - return this.assertManagedHomePath(managedHomePath) + return { + managedHomePath: this.assertManagedHomePath(managedHomePath), + managedHomeRuntime: 'host', + wslDistro: null, + wslLinuxHomePath: null + } + } + + private tryCreateWslManagedHome( + accountId: string, + target?: CodexAccountAddTarget + ): ManagedHomeLocation | null { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + + const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : [] + const infoOutput = execFileSync( + 'wsl.exe', + [...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'], + { encoding: 'utf-8', timeout: 5000 } + ) + const [rawDistro, rawHome] = infoOutput + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + const distro = target.wslDistro?.trim() || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Codex login.') + } + + const wslLinuxHomePath = `${home.replace(/\/$/, '')}/.local/share/orca/codex-accounts/${accountId}/home` + const markerPath = `${wslLinuxHomePath}/.orca-managed-home` + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + `mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}` + ], + { encoding: 'utf-8', timeout: 5000 } + ) + + const managedHomePath = toWindowsWslPath(wslLinuxHomePath, distro) + let trustedManagedHomePath: string + try { + trustedManagedHomePath = this.assertManagedHomePath(managedHomePath) + } catch (error) { + this.safeRemoveWslManagedHomeCandidate(distro, wslLinuxHomePath, accountId) + throw error + } + + return { + managedHomePath: trustedManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: distro, + wslLinuxHomePath + } } private safeSyncCanonicalConfigToManagedHomes(): void { @@ -277,15 +429,10 @@ export class CodexAccountService { } private syncCanonicalConfigToManagedHomes(): void { - const canonicalConfig = this.readCanonicalConfig() - if (canonicalConfig === null) { - return - } - const settings = this.store.getSettings() for (const account of settings.codexManagedAccounts) { try { - this.syncCanonicalConfigIntoManagedHome(account.managedHomePath, canonicalConfig) + this.syncCanonicalConfigIntoManagedHome(account.managedHomePath) } catch (error) { console.warn('[codex-accounts] Failed to sync managed config:', error) } @@ -294,7 +441,7 @@ export class CodexAccountService { private syncCanonicalConfigIntoManagedHome( managedHomePath: string, - canonicalConfig = this.readCanonicalConfig() + canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) ): void { if (canonicalConfig === null) { return @@ -322,6 +469,31 @@ export class CodexAccountService { } } + private readCanonicalConfigForManagedHome(managedHomePath: string): string | null { + const wslInfo = parseWslUncPath(managedHomePath) + if (!wslInfo) { + return this.readCanonicalConfig() + } + + const managedRootMarker = '/.local/share/orca/codex-accounts/' + const markerIndex = wslInfo.linuxPath.indexOf(managedRootMarker) + if (markerIndex < 0) { + return null + } + const wslHome = wslInfo.linuxPath.slice(0, markerIndex) + const configPath = toWindowsWslPath(`${wslHome}/.codex/config.toml`, wslInfo.distro) + if (!existsSync(configPath)) { + return null + } + + try { + return readFileSync(configPath, 'utf-8') + } catch (error) { + console.warn('[codex-accounts] Failed to read WSL canonical config:', error) + return null + } + } + private writeManagedConfig(managedHomePath: string, contents: string): void { writeFileAtomically(join(managedHomePath, 'config.toml'), contents) } @@ -333,6 +505,62 @@ export class CodexAccountService { } private assertManagedHomePath(candidatePath: string): string { + const wslInfo = parseWslUncPath(candidatePath) + if (wslInfo) { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/codex-accounts/') || + !wslInfo.linuxPath.endsWith('/home') + ) { + throw new Error('Managed WSL Codex home is outside Orca account storage.') + } + + if (process.platform === 'win32') { + try { + const canonicalLinuxPath = execFileSync( + 'wsl.exe', + [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-home"', + 'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ).trim() + if (!canonicalLinuxPath) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Codex home is outside Orca account storage.', { + cause: error + }) + } + } + + if (wslInfo.linuxPath.split('/').includes('..')) { + throw new Error('Managed WSL Codex home is outside Orca account storage.') + } + if (!existsSync(candidatePath)) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + if (!existsSync(join(candidatePath, '.orca-managed-home'))) { + throw new Error('Managed Codex home is missing Orca ownership marker.') + } + return candidatePath + } + const rootPath = this.getManagedAccountsRoot() const resolvedCandidate = resolve(candidatePath) const resolvedRoot = resolve(rootPath) @@ -376,6 +604,48 @@ export class CodexAccountService { return canonicalCandidate } + private safeRemoveWslManagedHomeCandidate( + distro: string, + linuxHomePath: string, + expectedAccountId: string + ): void { + // Why: WSL home creation can fail after mkdir/marker write but before the + // path is trusted. Cleanup must prove the marker/account ID inside WSL. + try { + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + buildEncodedWslBashCommand( + [ + 'set -euo pipefail', + `candidate=${shellQuote(linuxHomePath)}`, + `expected_marker=${shellQuote(expectedAccountId)}`, + 'managed_root="${HOME%/}/.local/share/orca/codex-accounts"', + 'candidate_real=$(readlink -f -- "$candidate" 2>/dev/null || true)', + 'managed_root_real=$(readlink -f -- "$managed_root" 2>/dev/null || true)', + 'test -n "$candidate_real"', + 'test -n "$managed_root_real"', + 'case "$candidate_real" in "$managed_root_real"/*/home) ;; *) exit 0 ;; esac', + 'test -f "$candidate_real/.orca-managed-home"', + 'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"', + 'rm -rf -- "$candidate_real"', + 'parent_dir=$(dirname -- "$candidate_real")', + 'case "$parent_dir" in "$managed_root_real"/*) rmdir -- "$parent_dir" 2>/dev/null || true ;; esac' + ].join('\n') + ) + ], + { encoding: 'utf-8', timeout: 5000 } + ) + } catch (error) { + console.warn('[codex-accounts] Failed to clean up WSL managed home candidate:', error) + } + } + private safeRemoveManagedHome(candidatePath: string): void { let managedHomePath: string try { @@ -387,6 +657,15 @@ export class CodexAccountService { rmSync(managedHomePath, { recursive: true, force: true }) + if (parseWslUncPath(managedHomePath)) { + try { + rmSync(dirname(managedHomePath), { recursive: true, force: true }) + } catch { + // Best-effort cleanup + } + return + } + // Why: managed homes live at //home. Removing // just the home/ leaf leaves an empty / directory behind. try { @@ -405,22 +684,45 @@ export class CodexAccountService { private async runCodexLogin(managedHomePath: string): Promise { await new Promise((resolvePromise, rejectPromise) => { - const codexCommand = resolveCodexCommand() - // Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file - // (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute - // batch scripts directly without shell:true, but shell:true with an args - // array causes DEP0190 because args are concatenated, not escaped. - // Fix: detect batch scripts and invoke cmd.exe /c explicitly. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) - const child = spawn(spawnCmd, spawnArgs, { + const wslInfo = parseWslUncPath(managedHomePath) + const spawnConfig = wslInfo + ? { + command: 'wsl.exe', + args: [ + '-d', + wslInfo.distro, + '--', + 'bash', + '-lc', + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}; exec codex login` + ], + env: process.env, + codexCommand: 'codex' + } + : (() => { + const codexCommand = resolveCodexCommand() + // Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file + // (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute + // batch scripts directly without shell:true, but shell:true with an args + // array causes DEP0190 because args are concatenated, not escaped. + // Fix: detect batch scripts and invoke cmd.exe /c explicitly. + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) + return { + command: spawnCmd, + args: spawnArgs, + env: { + ...process.env, + CODEX_HOME: managedHomePath + }, + codexCommand + } + })() + const child = spawn(spawnConfig.command, spawnConfig.args, { stdio: ['ignore', 'pipe', 'pipe'], // Why: route through cmd.exe for .cmd/.bat entrypoints would otherwise // flash a console window in the packaged GUI app on Windows. windowsHide: true, - env: { - ...process.env, - CODEX_HOME: managedHomePath - } + env: spawnConfig.env }) let settled = false @@ -457,7 +759,7 @@ export class CodexAccountService { // Why: ENOENT can mean either the codex binary doesn't exist OR the // script's shebang interpreter (node) isn't in PATH. When we resolved // codex to a full path, ENOENT almost certainly means node is missing. - const isBareCommand = codexCommand === 'codex' + const isBareCommand = spawnConfig.codexCommand === 'codex' const message = isEnoent ? isBareCommand ? 'Codex CLI not found.' diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts index 669dc5dbda7..4621d140b35 100644 --- a/src/main/crash-reporting/process-gone-classification.test.ts +++ b/src/main/crash-reporting/process-gone-classification.test.ts @@ -143,6 +143,24 @@ describe('shouldRecordProcessGoneCrash', () => { expectedTeardown: 'none' }) ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'GPU', + reason: 'abnormal-exit', + exitCode: 512, + expectedTeardown: 'none' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'GPU', + reason: 'abnormal-exit', + exitCode: 8704, + expectedTeardown: 'none' + }) + ).toBe(false) expect( shouldRecordProcessGoneCrash({ source: 'child', @@ -163,6 +181,26 @@ describe('shouldRecordProcessGoneCrash', () => { expectedTeardown: 'none' }) ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'audio.mojom.AudioService', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'none' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'audio.mojom.AudioService', + reason: 'crashed', + exitCode: -1, + expectedTeardown: 'none' + }) + ).toBe(false) }) it('still records unknown child process crashes', () => { diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts index 0d0f3aad98d..1049eaca214 100644 --- a/src/main/crash-reporting/process-gone-classification.ts +++ b/src/main/crash-reporting/process-gone-classification.ts @@ -3,8 +3,11 @@ export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown' const WINDOWS_CONTROL_TERMINATION_EXIT_CODES = new Set([0xc000013a, 0x40010004]) const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu']) -const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set(['network.mojom.NetworkService']) -const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['crashed', 'killed']) +const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([ + 'audio.mojom.AudioService', + 'network.mojom.NetworkService' +]) +const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed']) function isWindowsControlTerminationExitCode(exitCode: number | null): boolean { if (exitCode === null) { @@ -56,8 +59,8 @@ export function shouldRecordProcessGoneCrash({ exitCode: number | null expectedTeardown: ExpectedTeardownScope }): boolean { - // Why: GPU and Network Service exits are recoverable Chromium child-process - // churn; treating them as app crashes creates noisy user prompts. + // Why: GPU, Network Service, and Audio Service exits are recoverable Chromium + // child-process churn; treating them as app crashes creates noisy user prompts. if (isRecoverableChromiumChildProcess({ source, processType, serviceName, reason })) { return false } diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 3140cc65993..3574eaa7a2f 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -17,6 +17,7 @@ import type { SubprocessHandle } from './session' function createMockSubprocess(): SubprocessHandle { return { pid: 55555, + getForegroundProcess: () => null, write() {}, resize() {}, kill() {}, diff --git a/src/main/daemon/daemon-main.test.ts b/src/main/daemon/daemon-main.test.ts index c462fdcfbd1..b65ac49d4e9 100644 --- a/src/main/daemon/daemon-main.test.ts +++ b/src/main/daemon/daemon-main.test.ts @@ -93,6 +93,7 @@ function createMockSubprocess() { let onExitCb: ((code: number) => void) | null = null return { pid: 99999, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 8f721c44553..4bcd7d610bf 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -33,6 +33,7 @@ function createMockSubprocess(): SubprocessHandle & { let onExitCb: ((code: number) => void) | null = null return { pid: 66666, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index c956c3a63b2..0d62bd33d55 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -134,6 +134,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // the override makes the daemon path behave the same as the in-process // LocalPtyProvider. shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, shellReadySupported: opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false }) @@ -323,8 +324,16 @@ export class DaemonPtyAdapter implements IPtyProvider { return false } - async getForegroundProcess(_id: string): Promise { - return null + async getForegroundProcess(id: string): Promise { + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'getForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } } async serialize(ids: string[]): Promise { diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts index eb50820993d..03ceb4b9ba6 100644 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ b/src/main/daemon/daemon-pty-provider.test.ts @@ -18,6 +18,7 @@ function createMockSubprocess(): SubprocessHandle & { let onExitCb: ((code: number) => void) | null = null return { pid: 77777, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 526af432444..794d5f44737 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -22,6 +22,7 @@ function createMockSubprocess(): SubprocessHandle & { let onExitCb: ((code: number) => void) | null = null return { pid: 55555, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 42c385c9464..02e8cc61fd2 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: this class owns the daemon socket protocol, + request routing, stream fanout, and session lifecycle in one place so + renderer/daemon request semantics stay auditable across platform branches. */ import { createServer, type Server, type Socket } from 'net' import { randomUUID } from 'crypto' import { performance } from 'perf_hooks' @@ -220,6 +223,7 @@ export class DaemonServer { envToDelete: p.envToDelete, command: p.command, shellOverride: p.shellOverride, + terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, streamClient: { @@ -301,6 +305,9 @@ export class DaemonServer { case 'getCwd': return { cwd: await this.host.getCwd(request.payload.sessionId) } + case 'getForegroundProcess': + return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) } + case 'clearScrollback': this.host.clearScrollback(request.payload.sessionId) return {} diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index c07953364d0..e4d5a3d99c4 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -21,6 +21,7 @@ function createMockSubprocess(): SubprocessHandle { let onExitCb: ((code: number) => void) | null = null return { pid: 88888, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 721ead728ae..bfeda791e8d 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -24,6 +24,7 @@ function createMockSubprocess(): SubprocessHandle { let onExitCb: ((code: number) => void) | null = null return { pid: 44444, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index daed3cced1e..02ef5c8bb00 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -147,6 +147,34 @@ describe('createPtySubprocess', () => { expect(handle.pid).toBe(42) }) + it('normalizes foreground process names from node-pty', () => { + const proc = mockPtyProcess() + proc.process = '/opt/homebrew/bin/codex' + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24 + }) + + expect(handle.getForegroundProcess()).toBe('codex') + }) + + it('treats node-pty terminal name as inconclusive foreground process', () => { + const proc = mockPtyProcess() + proc.process = 'xterm-256color' + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24 + }) + + expect(handle.getForegroundProcess()).toBeNull() + }) + it('does not inherit parent Orca pane identity when caller omits pane env', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -871,6 +899,43 @@ describe('createPtySubprocess', () => { ) }) + it('uses the preferred WSL distro for daemon WSL terminals with Windows cwd', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const cwd = mkdtempSync(join(tmpdir(), 'daemon-pty-wsl-distro-test-')) + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + rmSync(cwd, { recursive: true, force: true }) + } + + const normalizedCwd = cwd.replace(/\\/g, '/') + const driveMatch = normalizedCwd.match(/^([A-Za-z]):\/?(.*)$/) + const expectedLinuxCwd = driveMatch + ? `/mnt/${driveMatch[1].toLowerCase()}${driveMatch[2] ? `/${driveMatch[2]}` : ''}` + : '/mnt/c' + + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Debian', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + expect.objectContaining({ cwd: expect.any(String) }) + ) + }) + it('launches WSL for WSL worktree cwd even when a stale Windows shell override is present', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -912,7 +977,7 @@ describe('createPtySubprocess', () => { cols: 80, rows: 24, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', - env: { CODEX_HOME: 'C:\\Users\\jin\\.codex' } + env: { CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' } }) } finally { if (platform) { @@ -924,7 +989,96 @@ describe('createPtySubprocess', () => { 'wsl.exe', ['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"], expect.objectContaining({ - env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + env: expect.not.objectContaining({ + CODEX_HOME: expect.anything(), + ORCA_CODEX_HOME: expect.anything() + }) + }) + ) + }) + + it('does not pass a WSL managed Codex home into daemon Windows terminals', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + env: { + CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home', + ORCA_CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + env: expect.not.objectContaining({ + CODEX_HOME: expect.anything(), + ORCA_CODEX_HOME: expect.anything() + }) + }) + ) + }) + + it('routes daemon default WSL terminals to the Codex home distro without losing cwd', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const cwd = mkdtempSync(join(tmpdir(), 'daemon-pty-wsl-codex-home-cwd-')) + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd, + shellOverride: 'wsl.exe', + env: { + CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home', + ORCA_CODEX_HOME: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + rmSync(cwd, { recursive: true, force: true }) + } + + const normalizedCwd = cwd.replace(/\\/g, '/') + const driveMatch = normalizedCwd.match(/^([A-Za-z]):\/?(.*)$/) + const expectedLinuxCwd = driveMatch + ? `/mnt/${driveMatch[1].toLowerCase()}${driveMatch[2] ? `/${driveMatch[2]}` : ''}` + : '/mnt/c' + + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`], + expect.objectContaining({ + env: expect.objectContaining({ + CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', + ORCA_CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', + WSLENV: expect.stringContaining('CODEX_HOME') + }) }) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index c84f003f2ee..182612cb1a0 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -19,9 +19,10 @@ import { import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershell' import { isPwshAvailable } from '../pwsh' -import { isHostCodexHomeForWsl } from '../pty/codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { parseWslPath } from '../wsl' +import { addWslEnvKeys } from '../wsl-env' import { getWslContextFromSessionId } from './wsl-session-context' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -38,6 +39,7 @@ export type PtySubprocessOptions = { * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user * who picks "New WSL terminal" from the "+" menu actually gets WSL. */ shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } @@ -81,6 +83,13 @@ function removeInheritedDevAgentHookEndpoint( } } +function getWslContextFromPreferredDistro( + distro: string | null | undefined +): { distro: string } | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed } : undefined +} + function removeInheritedElectronRunAsNode(env: Record): void { // Why: the daemon needs ELECTRON_RUN_AS_NODE=1 internally, but user shells // must not inherit it or nested Electron commands run as plain Node. @@ -165,6 +174,14 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): return formatted } +function normalizeForegroundProcessName(processName: string | null | undefined): string | null { + const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' + if (!trimmed || trimmed === 'xterm-256color') { + return null + } + return trimmed.split(/[\\/]/).pop() || null +} + export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle { const size = normalizePtySize(opts.cols, opts.rows) const env: Record = { @@ -205,13 +222,18 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl const cwdWslInfo = process.platform === 'win32' ? parseWslPath(opts.cwd ?? '') : null const sessionWslContext = process.platform === 'win32' ? getWslContextFromSessionId(opts.sessionId) : undefined + const preferredWslContext = + process.platform === 'win32' + ? getWslContextFromPreferredDistro(opts.terminalWindowsWslDistro) + : undefined // Why: WSL worktree cwd is the repo's execution environment. Older persisted // tabs can carry a PowerShell/cmd shellOverride; ignore it so reconnects and // daemon-backed terminals enter the WSL distro just like LocalPtyProvider. let shellPath = cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] - let spawnCwd = opts.cwd || getDefaultCwd() + const requestedCwd = opts.cwd || getDefaultCwd() + let spawnCwd = requestedCwd let validationCwd = spawnCwd if (process.platform === 'win32') { @@ -245,18 +267,57 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, spawnCwd, getDefaultCwd(), - sessionWslContext + sessionWslContext ?? preferredWslContext ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd - if ( - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' && - isHostCodexHomeForWsl(env.CODEX_HOME) - ) { - // Why: Orca's selected Codex runtime home is host-local. WSL Codex must - // use its Linux-side ~/.codex instead of inheriting a Windows path. + const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + const launchWslDistro = + cwdWslInfo?.distro ?? sessionWslContext?.distro ?? preferredWslContext?.distro + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else { + env.CODEX_HOME = codexHomeWslInfo.linuxPath + env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + // Why: wsl.exe only imports non-default env vars named in WSLENV. + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + requestedCwd, + getDefaultCwd(), + { + distro: codexHomeWslInfo.distro + } + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + } + } + } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { + // Why: Orca's selected Codex runtime home is host-local. WSL Codex + // must use its Linux-side ~/.codex instead of a Windows path. + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else if (env.CODEX_HOME) { + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (env.CLAUDE_CONFIG_DIR) { + // Why: managed WSL Claude accounts pass a Linux CLAUDE_CONFIG_DIR + // through Windows wsl.exe; non-default env vars need WSLENV import. + addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { + // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use + // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore + // CODEX_HOME from it after user profiles run. delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME } } else { // Why: any Orca-injected overlay env that user rc files can clobber @@ -333,6 +394,20 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, + getForegroundProcess: () => { + // Why: node-pty's `.process` getter reports the PTY's live foreground + // process name (the agent running in the shell, or the shell itself) and + // updates as it changes. Null once the child is gone — `.process` on a + // reaped pty can read a recycled pid. + if (dead) { + return null + } + try { + return normalizeForegroundProcessName(proc.process) + } catch { + return null + } + }, write: (data) => { if (dead) { return diff --git a/src/main/daemon/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts index c9a4bc32a9d..8735b76be53 100644 --- a/src/main/daemon/reattach-snapshot.test.ts +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -12,6 +12,7 @@ function createMockSubprocess(): SubprocessHandle & { let onExitCb: ((code: number) => void) | null = null return { pid: 42, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index fc312338173..f1215d09bcd 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -20,6 +20,9 @@ function createMockSubprocess() { get pid() { return pid }, + getForegroundProcess(): string | null { + return null + }, write(data: string) { written.push(data) }, diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index b2cb46fd7d1..bd41c075e2f 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -10,6 +10,9 @@ const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready\x07' export type SubprocessHandle = { pid: number + /** Live foreground process name of the PTY (node-pty's `.process`), e.g. + * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ + getForegroundProcess(): string | null write(data: string): void resize(cols: number, rows: number): void kill(): void @@ -184,6 +187,10 @@ export class Session { return this.emulator.getCwd() } + getForegroundProcess(): string | null { + return this.subprocess.getForegroundProcess() + } + clearScrollback(): void { if (this._disposed) { return diff --git a/src/main/daemon/terminal-host-startup.test.ts b/src/main/daemon/terminal-host-startup.test.ts index 9474b870439..aa6ae763888 100644 --- a/src/main/daemon/terminal-host-startup.test.ts +++ b/src/main/daemon/terminal-host-startup.test.ts @@ -5,6 +5,7 @@ import type { SubprocessHandle } from './session' function mockSubprocess(): SubprocessHandle { return { pid: 1, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(), diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 314ada72fa7..4f998e46030 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -8,6 +8,7 @@ function createMockSubprocess(): SubprocessHandle { let onExitCb: ((code: number) => void) | null = null return { pid: 99999, + getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 672eddd4805..ef3e4a8a9d0 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -19,6 +19,7 @@ export type CreateOrAttachOptions = { * daemon path honors per-tab shell selection the same way LocalPtyProvider * does. */ shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean streamClient: { onData: (data: string) => void; onExit: (code: number) => void } @@ -42,6 +43,7 @@ export type TerminalHostOptions = { envToDelete?: string[] command?: string shellOverride?: string + terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' }) => SubprocessHandle // Why: on graceful shutdown, the host writes final checkpoints for all live @@ -106,6 +108,7 @@ export class TerminalHost { envToDelete: opts.envToDelete, command: opts.command, shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation }) @@ -182,6 +185,16 @@ export class TerminalHost { return resolved || null } + // Why: returns null (not throws) for a dead/missing session — this is fetched + // for the tab-bar icon, so a vanished pane should quietly yield "no agent". + getForegroundProcess(sessionId: string): string | null { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return null + } + return session.getForegroundProcess() + } + clearScrollback(sessionId: string): void { this.getAliveSession(sessionId).clearScrollback() } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a1b5501db18..e9f3c966ddd 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,10 +1,10 @@ // ─── Protocol Version ──────────────────────────────────────────────── // Why: daemons can survive app updates. Bump for IPC wire-shape changes, or // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bumped from 9 -> 10 so affected v9 daemons are preserved as legacy -// instead of being resolver-health replaced while they own live PTY sessions. -export const PROTOCOL_VERSION = 10 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const +// Why: bump when adding daemon wire behavior so same-version old daemons do +// not silently accept the handshake and then reject new RPCs. +export const PROTOCOL_VERSION = 11 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' @@ -71,6 +71,8 @@ export type CreateOrAttachRequest = { * instead of defaulting to COMSPEC (which is always cmd.exe on Windows) * or the hard-coded powershell.exe fallback. */ shellOverride?: string + /** Preferred WSL distro for generic `wsl.exe` launches. */ + terminalWindowsWslDistro?: string | null /** Why: the UI keeps PowerShell as one shell family, but the runtime may * need to substitute pwsh.exe for powershell.exe when the user selected * PowerShell 7+. Forward the persisted implementation choice so the daemon @@ -145,6 +147,14 @@ export type GetCwdRequest = { } } +export type GetForegroundProcessRequest = { + id: string + type: 'getForegroundProcess' + payload: { + sessionId: string + } +} + export type ClearScrollbackRequest = { id: string type: 'clearScrollback' @@ -189,6 +199,7 @@ export type DaemonRequest = | ListSessionsRequest | DetachRequest | GetCwdRequest + | GetForegroundProcessRequest | ClearScrollbackRequest | ShutdownRequest | PingRequest diff --git a/src/main/daemon/wsl-session-context.ts b/src/main/daemon/wsl-session-context.ts index 372407f9a5c..f6d568a7042 100644 --- a/src/main/daemon/wsl-session-context.ts +++ b/src/main/daemon/wsl-session-context.ts @@ -4,6 +4,7 @@ import { parsePtySessionId } from './pty-session-id' export type WslSessionContext = { distro: string + treatPosixCwdAsWsl: true } export function getWslContextFromSessionId(sessionId: string): WslSessionContext | undefined { @@ -12,5 +13,5 @@ export function getWslContextFromSessionId(sessionId: string): WslSessionContext ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null - return wslInfo ? { distro: wslInfo.distro } : undefined + return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index f73a3e5f36f..b86b3eee4ca 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -8,7 +8,7 @@ vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -import { gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from './remote' +import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from './remote' describe('git remote operations', () => { beforeEach(() => { @@ -202,6 +202,37 @@ describe('git remote operations', () => { ]) }) + it('fast-forwards with --ff-only using the configured upstream', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitFastForward('/repo') + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }], + [['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }], + [['pull', '--ff-only'], { cwd: '/repo' }] + ]) + }) + + it('fast-forwards from the explicit publish target when one is provided', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitFastForward('/repo', { + remoteName: 'fork', + branchName: 'feature/fix' + }) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], + [['pull', '--ff-only', 'fork', 'feature/fix'], { cwd: '/repo' }] + ]) + }) + it('rebases from the selected remote base ref', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'origin\nupstream\n', stderr: '' }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 1de49dc385d..1847da2da43 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -76,14 +76,15 @@ export async function gitPush( } } -export async function gitPull(worktreePath: string, pushTarget?: GitPushTarget): Promise { - // Why: plain `git pull` uses the user's configured pull strategy (merge by - // default) so diverged branches reconcile instead of erroring out. Conflicts - // surface through the existing conflict-resolution flow. +async function gitPullWithArgs( + worktreePath: string, + pullArgs: string[], + pushTarget?: GitPushTarget +): Promise { try { if (pushTarget) { const target = await validateGitPushTarget(worktreePath, pushTarget) - await gitExecFileAsync(['pull', target.remoteName, target.branchName], { + await gitExecFileAsync(['pull', ...pullArgs, target.remoteName, target.branchName], { cwd: worktreePath }) return @@ -94,18 +95,32 @@ export async function gitPull(worktreePath: string, pushTarget?: GitPushTarget): if (upstream && !upstream.isConfiguredUpstream) { // Why: legacy Orca branches may still track origin/main while pushes // target origin/. Pull the same effective branch the UI reports. - await gitExecFileAsync(['pull', upstream.remoteName, upstream.branchName], { + await gitExecFileAsync(['pull', ...pullArgs, upstream.remoteName, upstream.branchName], { cwd: worktreePath }) return } - await gitExecFileAsync(['pull'], { cwd: worktreePath }) + await gitExecFileAsync(['pull', ...pullArgs], { cwd: worktreePath }) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'pull')) } } +export async function gitPull(worktreePath: string, pushTarget?: GitPushTarget): Promise { + // Why: plain `git pull` uses the user's configured pull strategy (merge by + // default) so diverged branches reconcile instead of erroring out. Conflicts + // surface through the existing conflict-resolution flow. + await gitPullWithArgs(worktreePath, [], pushTarget) +} + +export async function gitFastForward( + worktreePath: string, + pushTarget?: GitPushTarget +): Promise { + await gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget) +} + export async function gitPullRebaseFromBase(worktreePath: string, baseRef: string): Promise { try { const source = await resolveGitRemoteRebaseSource( diff --git a/src/main/git/repo-search-ref-compat.test.ts b/src/main/git/repo-search-ref-compat.test.ts new file mode 100644 index 00000000000..9e9659a11b2 --- /dev/null +++ b/src/main/git/repo-search-ref-compat.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + +import { searchBaseRefs } from './repo' + +describe('searchBaseRefs git compatibility', () => { + afterEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('falls back when older git does not support for-each-ref --exclude', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args.includes('--exclude=refs/remotes/**/HEAD')) { + throw Object.assign(new Error("unknown option `exclude'"), { + stderr: "error: unknown option `exclude'" + }) + } + return { + stdout: [ + 'refs/remotes/origin/main\0origin/main', + 'refs/remotes/origin/HEAD\0origin/HEAD' + ].join('\n'), + stderr: '' + } + }) + + await expect(searchBaseRefs('/repo', '', 1)).resolves.toEqual(['origin/main']) + const forEachRefCalls = gitExecFileAsyncMock.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'for-each-ref' + ) + expect(forEachRefCalls).toHaveLength(2) + expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD') + expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD') + expect(forEachRefCalls[1][0]).toContain('--count=104') + }) +}) diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 8cdb1852b45..8521d34698e 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'os' import path from 'path' import { + buildSearchBaseRefsArgv, getDefaultBaseRef, getBranchConflictKind, getRemoteCount, @@ -218,10 +219,32 @@ describe('searchBaseRefs (widened glob)', () => { expect(results).toEqual([]) }) - it('returns [] without error for an empty query', async () => { + it('returns recent refs for an empty query so branch pickers can open populated', async () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'upstream/main', sha) + createRemoteRef(tmpDir, 'upstream/feature-x', sha) + const results = await searchBaseRefs(tmpDir, '') - expect(results).toEqual([]) + expect(results).toEqual(['main', 'upstream/feature-x', 'upstream/main']) + }) + + it('caps broad ref-search argv before git output is captured', () => { + const argv = buildSearchBaseRefsArgv('', 12) + + expect(argv).toContain('--exclude=refs/remotes/**/HEAD') + expect(argv).toContain('--count=48') + }) + + it('does not hard-cap large explicit ref-search limits below the request size', () => { + const argv = buildSearchBaseRefsArgv('', 600) + + expect(argv).toContain('--count=2400') + }) + + it('returns [] for invalid search limits instead of running an uncapped search', async () => { + await expect(searchBaseRefs(tmpDir, '', 0.5)).resolves.toEqual([]) + await expect(searchBaseRefs(tmpDir, '', Number.NaN)).resolves.toEqual([]) }) // Why: the picker displays results as `/` and labels diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index ad23d8e777d..fff8ad5a558 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -478,8 +478,39 @@ async function getDefaultBaseRefAsync(path: string): Promise { * Why shared: the local path and the SSH relay path must send the exact * same argv so results cannot diverge between transports. */ -export function buildSearchBaseRefsArgv(normalizedQuery: string): string[] { - const base = ['for-each-ref', '--format=%(refname)%00%(refname:short)', '--sort=-committerdate'] +const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 +const REF_SEARCH_LEGACY_HEADROOM = 100 + +function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER + return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM +} + +export function buildSearchBaseRefsArgv( + normalizedQuery: string, + limit: number, + options: { excludeRemoteHead?: boolean } = {} +): string[] { + const excludeRemoteHead = options.excludeRemoteHead ?? true + const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) + const base = [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)', + '--sort=-committerdate', + ...(excludeRemoteHead + ? [ + // Why: exclude remote HEAD pseudo-refs before --count so the bounded + // candidate window is spent on refs the picker can actually display. + '--exclude=refs/remotes/**/HEAD' + ] + : []), + // Why: empty Branch-tab searches use broad globs; cap git output before + // execFile/SSH buffers capture every ref in very large repositories. + `--count=${candidateCount}` + ] // Why: split on `/` so display-format queries (`upstream/main`) route // each token to one git ref segment. Filter empty tokens so trailing // (`upstream/`), leading (`/main`), or doubled (`upstream//main`) @@ -512,6 +543,18 @@ export function buildSearchBaseRefsArgv(normalizedQuery: string): string[] { return [...base, `refs/remotes/${segmented}`, `refs/heads/${segmented}`] } +export function isForEachRefExcludeUnsupportedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const maybe = error as { message?: unknown; stderr?: unknown; stdout?: unknown } + const text = [maybe.message, maybe.stderr, maybe.stdout] + .filter((value): value is string => typeof value === 'string') + .join('\n') + .toLowerCase() + return text.includes('unknown option') && text.includes('exclude') +} + /** * Resolve the default push remote for a repo. * Order: remote configured on the current default branch → origin → the single @@ -578,20 +621,32 @@ export async function searchBaseRefDetails( query: string, limit = 25 ): Promise { - const normalizedQuery = normalizeRefSearchQuery(query) - if (!normalizedQuery) { + if (!Number.isInteger(limit) || limit <= 0) { return [] } + const normalizedQuery = normalizeRefSearchQuery(query) try { // Why: argv (including the two-remote-glob rationale) lives in // buildSearchBaseRefsArgv so the SSH sibling cannot drift. - const [{ stdout }, remotes] = await Promise.all([ - gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery), { cwd: path }), - listRemoteNames(path) - ]) + const remotesPromise = listRemoteNames(path) + let result: { stdout: string } + try { + result = await gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery, limit), { + cwd: path + }) + } catch (err) { + if (!isForEachRefExcludeUnsupportedError(err)) { + throw err + } + result = await gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }), + { cwd: path } + ) + } + const remotes = await remotesPromise - return parseAndFilterSearchRefDetails(stdout, limit, remotes) + return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) } catch (err) { // Why: surface the failure for diagnostics; callers treat `[]` as "no // matches", but silently swallowing the error makes a missing result diff --git a/src/main/git/status-discard-symlink.test.ts b/src/main/git/status-discard-symlink.test.ts index 0f0bfaad801..88a22d06009 100644 --- a/src/main/git/status-discard-symlink.test.ts +++ b/src/main/git/status-discard-symlink.test.ts @@ -91,6 +91,53 @@ describe('discardChanges symlink safety', () => { await expect(access(path.join(repo, 'ignored'))).rejects.toThrow() }) + it('treats untracked discard paths with Git glob characters as literal paths', async () => { + const { repo } = await createRepoWithOutsideDirectory() + await writeFile(path.join(repo, '.gitignore'), 'ignored.log\n') + execFileSync('git', ['add', '.gitignore'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'ignore log fixture'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected') + await writeFile(path.join(repo, 'keep.log'), 'unrelated') + await writeFile(path.join(repo, 'ignored.log'), 'ignored') + + await discardChanges(repo, '*.log') + + await expect(access(path.join(repo, '*.log'))).rejects.toThrow() + await expect(access(path.join(repo, 'keep.log'))).resolves.toBeUndefined() + await expect(access(path.join(repo, 'ignored.log'))).resolves.toBeUndefined() + }) + + it('treats tracked discard paths with Git glob characters as literal paths', async () => { + const { repo } = await createRepoWithOutsideDirectory() + await writeFile(path.join(repo, '*.log'), 'selected') + await writeFile(path.join(repo, 'keep.log'), 'keep') + execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'track log fixtures'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected modified') + await writeFile(path.join(repo, 'keep.log'), 'keep modified') + + await discardChanges(repo, '*.log') + + await expect(readFile(path.join(repo, '*.log'), 'utf8')).resolves.toBe('selected') + await expect(readFile(path.join(repo, 'keep.log'), 'utf8')).resolves.toBe('keep modified') + }) + + it('treats bulk untracked discard paths with Git glob characters as literal paths', async () => { + const { repo } = await createRepoWithOutsideDirectory() + await writeFile(path.join(repo, '.gitignore'), 'ignored.log\n') + execFileSync('git', ['add', '.gitignore'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'ignore log fixture'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected') + await writeFile(path.join(repo, 'keep.log'), 'unrelated') + await writeFile(path.join(repo, 'ignored.log'), 'ignored') + + await bulkDiscardChanges(repo, ['*.log']) + + await expect(access(path.join(repo, '*.log'))).rejects.toThrow() + await expect(access(path.join(repo, 'keep.log'))).resolves.toBeUndefined() + await expect(access(path.join(repo, 'ignored.log'))).resolves.toBeUndefined() + }) + it('removes untracked nested git repos selected for discard', async () => { const { repo } = await createRepoWithOutsideDirectory() const nestedRepo = path.join(repo, 'nested') diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 2547f60b7b3..252b23ff608 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -75,14 +75,14 @@ describe('discardChanges', () => { expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['ls-files', '--error-unmatch', '--', 'src/file.ts'], + ['ls-files', '--error-unmatch', '--', ':(literal)src/file.ts'], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--worktree', '--source=HEAD', '--', 'src/file.ts'], + ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src/file.ts'], { cwd: '/repo' } @@ -98,7 +98,7 @@ describe('discardChanges', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['clean', '-ffdx', '--', 'src/new-file.ts'], + ['clean', '-ffdx', '--', ':(literal)src/new-file.ts'], { cwd: '/repo' } @@ -178,7 +178,15 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['ls-files', '-z', '--', 'src/file.ts', 'src/new-file.ts', 'docs', 'scratch'], + [ + 'ls-files', + '-z', + '--', + ':(literal)src/file.ts', + ':(literal)src/new-file.ts', + ':(literal)docs', + ':(literal)scratch' + ], { cwd: '/repo' } @@ -187,14 +195,14 @@ describe('bulk git helpers', () => { // tracked descendant, which keeps directory pathspecs on the restore path. expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--worktree', '--source=HEAD', '--', 'src/file.ts', 'docs'], + ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src/file.ts', ':(literal)docs'], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 3, - ['clean', '-ffdx', '--', 'src/new-file.ts', 'scratch'], + ['clean', '-ffdx', '--', ':(literal)src/new-file.ts', ':(literal)scratch'], { cwd: '/repo' } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index ed34cd56bdf..5ab2c757d83 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1105,7 +1105,7 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr let tracked = false try { - await gitExecFileAsync(['ls-files', '--error-unmatch', '--', filePath], { + await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], { cwd: worktreePath }) tracked = true @@ -1114,9 +1114,12 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr } if (tracked) { - await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], { - cwd: worktreePath - }) + await gitExecFileAsync( + ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)], + { + cwd: worktreePath + } + ) return } @@ -1129,6 +1132,11 @@ function normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } +function literalPathspec(filePath: string): string { + // Why: source-control selections are concrete paths, not user-authored Git globs. + return `:(literal)${filePath}` +} + function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { const normalized = normalizeGitPathForCompare(filePath) return trackedPaths.some((trackedPath) => { @@ -1144,9 +1152,12 @@ async function listTrackedPathSpecs( const trackedPaths: string[] = [] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - const { stdout } = await gitExecFileAsync(['ls-files', '-z', '--', ...chunk], { - cwd: worktreePath - }) + const { stdout } = await gitExecFileAsync( + ['ls-files', '-z', '--', ...chunk.map(literalPathspec)], + { + cwd: worktreePath + } + ) trackedPaths.push(...stdout.split('\0').filter(Boolean)) } return trackedPaths @@ -1160,7 +1171,9 @@ async function cleanUntrackedPaths( const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk], { cwd: worktreePath }) + await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk.map(literalPathspec)], { + cwd: worktreePath + }) } } } @@ -1193,9 +1206,12 @@ export async function bulkDiscardChanges(worktreePath: string, filePaths: string async () => { for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', ...chunk], { - cwd: worktreePath - }) + await gitExecFileAsync( + ['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)], + { + cwd: worktreePath + } + ) } } ) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index c2eb7c042a7..d65c37841b6 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -278,9 +278,18 @@ describe('GitHub issue source split', () => { const item = await getWorkItem('/repo-root', 42, 'pr') expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/42'], { - cwd: '/repo-root' - }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '42', + '--repo', + 'fork/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root' } + ) expect(item?.type).toBe('pr') }) @@ -311,9 +320,19 @@ describe('GitHub issue source split', () => { ['api', 'repos/stablyai/orca/issues/42'], { cwd: '/repo-root' } ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/fork/orca/pulls/42'], { - cwd: '/repo-root' - }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + [ + 'pr', + 'view', + '42', + '--repo', + 'fork/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root' } + ) expect(item?.type).toBe('pr') }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index b6620d4f3b8..a2e720a362b 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -91,8 +91,10 @@ import { getPullRequestPushTarget, mergePR, resolveReviewThread, + setPRAutoMerge, updatePRTitle, - _resetOwnerRepoCache + _resetOwnerRepoCache, + _resetMergeQueueCacheForTests } from './client' describe('getPRForBranch', () => { @@ -120,6 +122,7 @@ describe('getPRForBranch', () => { releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() }) it('queries GitHub by head branch when the remote is on github.com', async () => { @@ -230,7 +233,7 @@ describe('getPRForBranch', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -243,7 +246,7 @@ describe('getPRForBranch', () => { '--repo', 'fork/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -282,7 +285,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -373,7 +376,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -464,7 +467,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -513,30 +516,48 @@ describe('getPRForBranch', () => { it('prefers branch lookup over a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 43, + title: 'Branch PR wins', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/43', + updated_at: '2026-03-28T00:00:00Z', + draft: false, + mergeable: true, + head: { ref: 'feature/test', sha: 'branch-head-oid' }, + base: { ref: 'main', sha: 'branch-base-oid' } + } + ]) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ number: 43, - title: 'Branch PR wins', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/43', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - head: { ref: 'feature/test', sha: 'branch-head-oid' }, - base: { ref: 'main', sha: 'branch-base-oid' } - } - ]) - }) + title: 'Hydrated branch PR wins', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/43', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'branch-base-oid', + headRefOid: 'branch-head-oid' + }) + }) const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, ['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'], { cwd: '/repo-root' } ) - expect(pr).toMatchObject({ number: 43, title: 'Branch PR wins' }) + expect(pr).toMatchObject({ number: 43, title: 'Hydrated branch PR wins' }) }) it('uses a fallback PR number only after branch lookup misses', async () => { @@ -576,7 +597,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -613,13 +634,54 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr?.number).toBe(77) }) + it('normalizes exact linked PR fallback metadata when no GitHub remote is resolved', async () => { + getOwnerRepoMock.mockResolvedValueOnce(null) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 77, + title: 'Linked fallback PR', + state: 'OPEN', + url: 'https://example.com/pr/77', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: '', + autoMergeRequest: { enabledAt: '2026-03-28T00:00:00Z' }, + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + + const pr = await getPRForBranch('/non-github-repo', 'feature/test', 77) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '77', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/non-github-repo' } + ) + expect(pr).toMatchObject({ + number: 77, + reviewDecision: null, + autoMergeEnabled: true + }) + expect(pr?.mergeQueueRequired).toBeUndefined() + }) + it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => { getOwnerRepoMock.mockResolvedValueOnce(null) ghExecFileAsyncMock.mockResolvedValueOnce({ @@ -647,7 +709,7 @@ describe('getPRForBranch', () => { 'view', 'feature/test', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/non-github-repo' } ) @@ -823,7 +885,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -871,7 +933,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -1039,6 +1101,7 @@ describe('GitHub GraphQL rate-limit guard', () => { releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() }) it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { @@ -1140,7 +1203,7 @@ describe('GitHub GraphQL rate-limit guard', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -1159,6 +1222,149 @@ describe('GitHub GraphQL rate-limit guard', () => { ) }) + it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await expect( + setPRAutoMerge('/remote/repo-root', 7, true, 'ssh-1', { + owner: 'stablyai', + repo: 'orca' + }) + ).resolves.toEqual({ ok: true }) + await expect( + setPRAutoMerge('/remote/repo-root', 7, false, 'ssh-1', { + owner: 'stablyai', + repo: 'orca' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['pr', 'merge', '7', '--auto', '--repo', 'stablyai/orca'], + expect.objectContaining({ + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + }) + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], + expect.objectContaining({ + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + }) + ) + expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd') + }) + + it('blocks direct merge when GitHub reports required approval', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'REVIEW_REQUIRED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toEqual({ + ok: false, + error: 'This pull request requires review approval before it can be merged.' + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql') + }) + + it('detects merge queues once per base branch and blocks direct merges', async () => { + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'true', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toEqual({ + ok: false, + error: + 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' + }) + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toMatchObject({ ok: false }) + + expect( + ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) + ).toHaveLength(1) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual( + expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true']) + ) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F') + }) + + it('caches unknown merge queue probes after GraphQL failures', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockRejectedValueOnce(new Error('network is down')) + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + + await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ + mergeQueueRequired: null + }) + await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ + mergeQueueRequired: null + }) + + expect( + ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) + ).toHaveLength(1) + }) + it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -1197,14 +1403,31 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('does not run merge conflict preflight for SSH-backed repos', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'CONFLICTING', + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca' }) ).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index e9cb2177af7..fb5c5be0a3b 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -9,6 +9,7 @@ import type { PRConflictSummary, PRRefreshOutcome, PRMergeableState, + PRReviewDecision, PRCheckDetail, PRCheckRunDetails, GitHubCommentResult, @@ -83,6 +84,13 @@ import { type GhExecOptions = ReturnType const ORCA_REPO = 'stablyai/orca' +const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 +const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 +const mergeQueueRequiredCache = new Map() + +export function _resetMergeQueueCacheForTests(): void { + mergeQueueRequiredCache.clear() +} async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise { await getRateLimit() @@ -346,7 +354,7 @@ const WORK_ITEM_PR_LIST_JSON_FIELDS = // GraphQL work across every row. Requested reviewers are kept in the list // payload because the Tasks table renders that column on first paint. const WORK_ITEM_PR_DETAIL_JSON_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify' + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify' function mapIssueWorkItem(item: Record): MainWorkItem { return { @@ -509,6 +517,16 @@ function normalizePRMergeable(value: unknown): PRMergeableState | undefined { return undefined } +function normalizeReviewDecision(value: unknown): PRReviewDecision | null { + return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED' + ? value + : null +} + +function isAutoMergeEnabled(value: unknown): boolean { + return typeof value === 'object' && value !== null +} + function checkRollupEntries(value: unknown): unknown[] { if (Array.isArray(value)) { return value @@ -637,7 +655,7 @@ function mapPullRequestWorkItem( ...(deletions !== undefined ? { deletions } : {}), ...(changedFiles !== undefined ? { changedFiles } : {}), ...('reviewDecision' in item - ? { reviewDecision: typeof item.reviewDecision === 'string' ? item.reviewDecision : null } + ? { reviewDecision: normalizeReviewDecision(item.reviewDecision) } : {}), ...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined ? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) } @@ -650,6 +668,9 @@ function mapPullRequestWorkItem( ? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) } : {}), ...(mergeable ? { mergeable } : {}), + ...('autoMergeRequest' in item + ? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) } + : {}), ...('mergeStateStatus' in item ? { mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null @@ -696,11 +717,31 @@ async function fetchPullRequestWorkItem( ): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - ghOptions - ) - return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + try { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + WORK_ITEM_PR_DETAIL_JSON_FIELDS + ], + ghOptions + ) + const item = JSON.parse(stdout) as Record + const mapped = mapPullRequestWorkItem(item, ownerRepo) + const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined + const mergeQueueRequired = await detectMergeQueueRequired(ownerRepo, baseRefName, ghOptions) + return { ...mapped, mergeQueueRequired } + } catch { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], + ghOptions + ) + return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + } } const { stdout } = await ghExecFileAsync( @@ -1647,6 +1688,11 @@ type PullRequestLookupData = { updatedAt: string isDraft?: boolean mergeable: string + reviewDecision?: PRReviewDecision | null + autoMergeRequest?: unknown + autoMergeEnabled?: boolean + mergeQueueRequired?: boolean | null + mergeStateStatus?: string | null baseRefName?: string headRefName?: string baseRefOid?: string @@ -1669,6 +1715,8 @@ type RestPullRequest = { } const PR_LOOKUP_JSON_FIELDS = + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' +const PR_BRANCH_LIST_JSON_FIELDS = 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState { @@ -1699,6 +1747,108 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { } } +function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData { + return { + ...data, + reviewDecision: + data.reviewDecision !== undefined ? normalizeReviewDecision(data.reviewDecision) : undefined, + autoMergeEnabled: + data.autoMergeEnabled ?? + ('autoMergeRequest' in data ? isAutoMergeEnabled(data.autoMergeRequest) : undefined) + } +} + +function cacheMergeQueueRequired(cacheKey: string, value: boolean | null, ttlMs: number): void { + mergeQueueRequiredCache.set(cacheKey, { + value, + expiresAt: Date.now() + ttlMs + }) +} + +async function detectMergeQueueRequired( + ownerRepo: OwnerRepo, + branchName: string | undefined, + ghOptions: GhExecOptions +): Promise { + if (!branchName) { + return null + } + const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${branchName}` + const cached = mergeQueueRequiredCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.value + } + const guard = rateLimitGuard('graphql') + if (guard.blocked) { + return null + } + const query = `query($owner: String!, $repo: String!, $branch: String!) { + repository(owner: $owner, name: $repo) { + mergeQueue(branch: $branch) { id } + } + }` + try { + noteRateLimitSpend('graphql') + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-f', + `branch=${branchName}` + ], + ghOptions + ) + const parsed = JSON.parse(stdout) as { + data?: { repository?: { mergeQueue?: { id?: unknown } | null } | null } + } + const value = Boolean(parsed.data?.repository?.mergeQueue) + cacheMergeQueueRequired(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS) + return value + } catch { + // Why: failed merge-queue probes should stay conservative without + // retrying GraphQL on every status poll while GitHub/network is unhappy. + cacheMergeQueueRequired(cacheKey, null, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS) + return null + } +} + +async function hydratePullRequestLookupData( + ownerRepo: OwnerRepo, + data: PullRequestLookupData, + ghOptions: GhExecOptions +): Promise { + const normalized = normalizePullRequestLookupData(data) + const hasRichMergeFields = + 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data + return { + ...normalized, + mergeQueueRequired: hasRichMergeFields + ? await detectMergeQueueRequired(ownerRepo, normalized.baseRefName, ghOptions) + : undefined + } +} + +async function hydrateBranchLookupWithExactPR( + ownerRepo: OwnerRepo, + branchData: PullRequestLookupData | null, + ghOptions: GhExecOptions +): Promise { + if (!branchData) { + return null + } + try { + return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData + } catch { + return branchData + } +} + async function getRestPRForBranch( prRepo: OwnerRepo, headOwner: string, @@ -1733,7 +1883,7 @@ async function getFallbackPRListForBranch( '--limit', '1', '--json', - PR_LOOKUP_JSON_FIELDS + PR_BRANCH_LIST_JSON_FIELDS ], ghOptions ) @@ -1771,7 +1921,11 @@ async function getPRByNumber( ], ghOptions ) - return JSON.parse(stdout) as PullRequestLookupData + return hydratePullRequestLookupData( + ownerRepo, + JSON.parse(stdout) as PullRequestLookupData, + ghOptions + ) } catch (err) { // Why: deleted or manually edited linked PR metadata should fall back to // branch discovery; quota/auth/network failures get one cheaper REST exact lookup. @@ -1779,7 +1933,8 @@ async function getPRByNumber( return null } try { - return await getRestPRByNumber(ownerRepo, number, ghOptions) + const restData = await getRestPRByNumber(ownerRepo, number, ghOptions) + return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null } catch (restErr) { if (isNotFoundGhError(restErr)) { return null @@ -1821,7 +1976,10 @@ async function lookupPRByNumber(args: { ['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS], args.ghOptions ) - return { data: JSON.parse(stdout), dataRepo: null } + return { + data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), + dataRepo: null + } } catch (err) { if (isNoPullRequestError(err)) { // Why: stale cached fallback numbers should not turn every poll into an @@ -1908,9 +2066,12 @@ export async function getPRForBranchOutcome( if (candidates.length > 0) { for (const candidate of candidates) { try { - data = headRepo + const branchData = headRepo ? await getRestPRForBranch(candidate, headRepo.owner, branchName, ghOptions) : await getFallbackPRListForBranch(candidate, branchName, ghOptions) + // Why: REST/list branch lookup identifies the PR cheaply; exact + // `gh pr view` carries review, merge queue, and auto-merge state. + data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions) if (data) { dataRepo = candidate break @@ -1919,7 +2080,13 @@ export async function getPRForBranchOutcome( if (headRepo) { throw err } else { - data = await getRestPRForBranch(candidate, candidate.owner, branchName, ghOptions) + const branchData = await getRestPRForBranch( + candidate, + candidate.owner, + branchName, + ghOptions + ) + data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions) if (data) { dataRepo = candidate break @@ -1933,7 +2100,7 @@ export async function getPRForBranchOutcome( ['pr', 'view', branchName, '--json', PR_LOOKUP_JSON_FIELDS], ghOptions ) - data = JSON.parse(stdout) + data = normalizePullRequestLookupData(JSON.parse(stdout)) } catch (err) { if (isNoPullRequestError(err)) { data = null @@ -1976,6 +2143,12 @@ export async function getPRForBranchOutcome( checksStatus: deriveCheckStatus(data.statusCheckRollup), updatedAt: data.updatedAt, mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN', + ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), + ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), + ...(data.mergeQueueRequired !== undefined + ? { mergeQueueRequired: data.mergeQueueRequired } + : {}), + ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), headSha: data.headRefOid, prRepo: dataRepo ?? undefined, headRepo: headRepo ?? undefined, @@ -2889,6 +3062,35 @@ export async function mergePR( } } +export async function setPRAutoMerge( + repoPath: string, + prNumber: number, + enabled: boolean, + connectionId?: string | null, + prRepo?: OwnerRepo | null +): Promise<{ ok: true } | { ok: false; error: string }> { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + await acquire() + try { + const args = ['pr', 'merge', String(prNumber), enabled ? '--auto' : '--disable-auto'] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: classifyGhError(message).message } + } finally { + release() + } +} + async function getPRMergeBlocker( repoPath: string, prNumber: number, @@ -2896,15 +3098,33 @@ async function getPRMergeBlocker( ghOptions: GhExecOptions, connectionId?: string | null ): Promise { - // Why: conflict summaries shell out to local git; SSH repo paths are remote-only - // until that helper is routed through the SSH git provider. - if (!ownerRepo || connectionId) { + if (!ownerRepo) { return null } try { const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions) - if (pr?.mergeable !== 'CONFLICTING' || !pr.baseRefName || !pr.baseRefOid || !pr.headRefOid) { + if (!pr) { + return null + } + if (pr.reviewDecision === 'REVIEW_REQUIRED') { + return 'This pull request requires review approval before it can be merged.' + } + if (pr.reviewDecision === 'CHANGES_REQUESTED') { + return 'This pull request has requested changes and cannot be merged yet.' + } + if (pr.mergeQueueRequired === true) { + return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' + } + // Why: conflict summaries shell out to local git; SSH repo paths are remote-only + // until that helper is routed through the SSH git provider. + if ( + connectionId || + pr.mergeable !== 'CONFLICTING' || + !pr.baseRefName || + !pr.baseRefOid || + !pr.headRefOid + ) { return null } diff --git a/src/main/index.ts b/src/main/index.ts index 1a8b8908046..d1d9c5ff7ad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,12 +55,22 @@ import { import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' -import { acquireSingleInstanceLock } from './startup/single-instance-lock' +import { + acquireSingleInstanceLock, + logSingleInstanceLockFailure +} from './startup/single-instance-lock' import { RateLimitService } from './rate-limits/service' +import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './codex-accounts/runtime-selection' +import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -266,14 +276,9 @@ function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope const hasSingleInstanceLock = is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow) if (!hasSingleInstanceLock) { - if (is.dev) { - // Why: packaged runs have no attached console, but dev runs do. Emit a - // single line so a `pnpm dev` operator does not mistake a silent exit - // for a broken launcher. - console.log( - '[single-instance] Another Orca instance is already running against this userData path — focusing existing window.' - ) - } + // Why: if Electron returns a false negative here, packaged macOS launches + // otherwise look like silent crashes. `open --stderr` can capture this line. + logSingleInstanceLockFailure() app.quit() } @@ -307,8 +312,8 @@ if (hasSingleInstanceLock) { enableMainProcessGpuFeatures() } -function prepareCodexRuntimeHomeForLaunch(): string { - const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch() +function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { + const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) try { // Why: launch prep is reachable after startup via PTY/runtime paths; honor @@ -487,7 +492,7 @@ function openMainWindow(): BrowserWindow { store, runtime, prepareCodexRuntimeHomeForLaunch, - () => claudeRuntimeAuth!.prepareForClaudeLaunch(), + (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), { onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { if (window.webContents.id === webContentsId) { @@ -1048,8 +1053,14 @@ app.whenReady().then(async () => { codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) - rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch()) - rateLimits.setClaudeAuthPreparationResolver(() => claudeRuntimeAuth!.prepareForRateLimitFetch()) + rateLimits.setCodexHomePathResolver((target) => + codexRuntimeHome!.prepareForRateLimitFetch(target) + ) + rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + rateLimits.setClaudeAuthPreparationResolver((target) => + claudeRuntimeAuth!.prepareForRateLimitFetch(target) + ) rateLimits.setSettingsResolver(() => store!.getSettings()) keybindings = new KeybindingService({ homePath: app.getPath('home'), @@ -1058,14 +1069,32 @@ app.whenReady().then(async () => { browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() })) rateLimits.setInactiveClaudeAccountsResolver(() => { const settings = store!.getSettings() + const activeIds = new Set( + [ + normalizeClaudeRuntimeSelection(settings).host, + ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) return settings.claudeManagedAccounts - .filter((account) => account.id !== settings.activeClaudeManagedAccountId) - .map((account) => ({ id: account.id, managedAuthPath: account.managedAuthPath })) + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + managedAuthPath: account.managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + wslLinuxAuthPath: account.wslLinuxAuthPath + })) }) rateLimits.setInactiveCodexAccountsResolver(() => { const settings = store!.getSettings() + const activeIds = new Set( + [ + normalizeCodexRuntimeSelection(settings).host, + ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) return settings.codexManagedAccounts - .filter((account) => account.id !== settings.activeCodexManagedAccountId) + .filter((account) => !activeIds.has(account.id)) .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) }) const runtimeService = new OrcaRuntimeService(store, stats, { @@ -1249,7 +1278,7 @@ app.whenReady().then(async () => { runtime, prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), - () => claudeRuntimeAuth!.prepareForClaudeLaunch(), + (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), store ) // Why: headless servers have no renderer graph publisher. Publish an diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 679fe8d5650..711cf3982ff 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -46,6 +46,9 @@ vi.mock('../agent-hooks/server', async () => { vi.mock('../claude/hook-service', () => ({ claudeHookService: { getStatus: vi.fn(() => ({ agent: 'claude', state: 'absent' })) } })) +vi.mock('../openclaude/hook-service', () => ({ + openClaudeHookService: { getStatus: vi.fn(() => ({ agent: 'openclaude', state: 'absent' })) } +})) vi.mock('../codex/hook-service', () => ({ codexHookService: { getStatus: vi.fn(() => ({ agent: 'codex', state: 'absent' })) } })) @@ -55,6 +58,9 @@ vi.mock('../gemini/hook-service', () => ({ vi.mock('../antigravity/hook-service', () => ({ antigravityHookService: { getStatus: vi.fn(() => ({ agent: 'antigravity', state: 'absent' })) } })) +vi.mock('../amp/hook-service', () => ({ + ampHookService: { getStatus: vi.fn(() => ({ agent: 'amp', state: 'absent' })) } +})) vi.mock('../cursor/hook-service', () => ({ cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) } })) @@ -121,6 +127,28 @@ describe('agentHooks:antigravityStatus IPC', () => { }) }) +describe('agentHooks:ampStatus IPC', () => { + it('returns Amp hook installation status', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentHooks:ampStatus') + expect(handler).toBeDefined() + expect(handler!({})).toEqual({ agent: 'amp', state: 'absent' }) + }) +}) + +describe('agentHooks:openClaudeStatus IPC', () => { + it('returns OpenClaude hook installation status', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentHooks:openClaudeStatus') + expect(handler).toBeDefined() + expect(handler!({})).toEqual({ agent: 'openclaude', state: 'absent' }) + }) +}) + describe('agentHooks:commandCodeStatus IPC', () => { it('returns Command Code hook installation status', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 11e05618e6e..7637fd0d25d 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { ampHookService } from '../amp/hook-service' import { clearMigrationUnsupportedPtysForPaneKey, getMigrationUnsupportedPtySnapshot @@ -20,6 +21,7 @@ import { commandCodeHookService } from '../command-code/hook-service' import { grokHookService } from '../grok/hook-service' import { copilotHookService } from '../copilot/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a @@ -33,9 +35,11 @@ export function registerAgentHookHandlers(): void { // register-core-handlers.ts prevents re-entry, but decoupling from that guard // future-proofs this file. ipcMain.removeHandler('agentHooks:claudeStatus') + ipcMain.removeHandler('agentHooks:openClaudeStatus') ipcMain.removeHandler('agentHooks:codexStatus') ipcMain.removeHandler('agentHooks:geminiStatus') ipcMain.removeHandler('agentHooks:antigravityStatus') + ipcMain.removeHandler('agentHooks:ampStatus') ipcMain.removeHandler('agentHooks:cursorStatus') ipcMain.removeHandler('agentHooks:droidStatus') ipcMain.removeHandler('agentHooks:commandCodeStatus') @@ -99,6 +103,19 @@ export function registerAgentHookHandlers(): void { } } }) + ipcMain.handle('agentHooks:openClaudeStatus', (): AgentHookInstallStatus => { + try { + return openClaudeHookService.getStatus() + } catch (err) { + return { + agent: 'openclaude', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) ipcMain.handle('agentHooks:codexStatus', (): AgentHookInstallStatus => { try { return codexHookService.getStatus() @@ -138,6 +155,19 @@ export function registerAgentHookHandlers(): void { } } }) + ipcMain.handle('agentHooks:ampStatus', (): AgentHookInstallStatus => { + try { + return ampHookService.getStatus() + } catch (err) { + return { + agent: 'amp', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) ipcMain.handle('agentHooks:cursorStatus', (): AgentHookInstallStatus => { try { return cursorHookService.getStatus() diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 2fb289e3ece..d24e737d6fc 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -10,7 +10,7 @@ import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/ import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailable } from '../pwsh' -import { isWslAvailable } from '../wsl' +import { isWslAvailable, listWslDistros } from '../wsl' import { setUnreadDockBadgeCount } from '../dock/unread-badge' import { authorizeExternalPath } from './filesystem-auth' import { @@ -117,6 +117,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp }) ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) + ipcMain.handle('wsl:listDistros', (): string[] => listWslDistros()) ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) // Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts index 8d62c8c060a..2fbaff81ef8 100644 --- a/src/main/ipc/claude-accounts.ts +++ b/src/main/ipc/claude-accounts.ts @@ -1,16 +1,25 @@ import { ipcMain } from 'electron' -import type { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountAddTarget, ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void { ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts()) - ipcMain.handle('claudeAccounts:add', () => claudeAccounts.addAccount()) + ipcMain.handle('claudeAccounts:add', (_event, args?: ClaudeAccountAddTarget) => + claudeAccounts.addAccount(args) + ) ipcMain.handle('claudeAccounts:reauthenticate', (_event, args: { accountId: string }) => claudeAccounts.reauthenticateAccount(args.accountId) ) ipcMain.handle('claudeAccounts:remove', (_event, args: { accountId: string }) => claudeAccounts.removeAccount(args.accountId) ) - ipcMain.handle('claudeAccounts:select', (_event, args: { accountId: string | null }) => - claudeAccounts.selectAccount(args.accountId) + ipcMain.handle( + 'claudeAccounts:select', + (_event, args: { accountId: string | null } & ClaudeAccountSelectionTarget) => { + if (!args.runtime) { + return claudeAccounts.selectAccount(args.accountId) + } + return claudeAccounts.selectAccountForTarget(args.accountId, args) + } ) } diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 12b132eca51..4e5f195e98a 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -1,16 +1,28 @@ import { ipcMain } from 'electron' -import type { CodexAccountService } from '../codex-accounts/service' +import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' export function registerCodexAccountHandlers(codexAccounts: CodexAccountService): void { ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccounts()) - ipcMain.handle('codexAccounts:add', () => codexAccounts.addAccount()) + ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) => + codexAccounts.addAccount(args) + ) ipcMain.handle('codexAccounts:reauthenticate', (_event, args: { accountId: string }) => codexAccounts.reauthenticateAccount(args.accountId) ) ipcMain.handle('codexAccounts:remove', (_event, args: { accountId: string }) => codexAccounts.removeAccount(args.accountId) ) - ipcMain.handle('codexAccounts:select', (_event, args: { accountId: string | null }) => - codexAccounts.selectAccount(args.accountId) + ipcMain.handle( + 'codexAccounts:select', + (_event, args: { accountId: string | null } & CodexAccountSelectionTarget) => { + if (!args.runtime) { + // Why: older renderer surfaces selected by account id only. Let the + // service infer the account's runtime instead of treating missing + // runtime as Windows/host and rejecting valid WSL accounts. + return codexAccounts.selectAccount(args.accountId) + } + return codexAccounts.selectAccountForTarget(args.accountId, args) + } ) } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 05a295f3e61..3722855261f 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -31,9 +31,11 @@ const { listWorktreesMock, resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, + generatePullRequestFieldsFromContextMock, discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, + cancelGeneratePullRequestFieldsLocalMock, getSshFilesystemProviderMock, getSshGitProviderMock } = vi.hoisted(() => ({ @@ -64,9 +66,11 @@ const { listWorktreesMock: vi.fn(), resolveCommitMessageSettingsMock: vi.fn(), generateCommitMessageFromContextMock: vi.fn(), + generatePullRequestFieldsFromContextMock: vi.fn(), discoverCommitMessageModelsLocalMock: vi.fn(), discoverCommitMessageModelsRemoteMock: vi.fn(), cancelGenerateCommitMessageLocalMock: vi.fn(), + cancelGeneratePullRequestFieldsLocalMock: vi.fn(), getSshFilesystemProviderMock: vi.fn(), getSshGitProviderMock: vi.fn() })) @@ -139,9 +143,11 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ vi.mock('../text-generation/commit-message-text-generation', () => ({ resolveCommitMessageSettings: resolveCommitMessageSettingsMock, generateCommitMessageFromContext: generateCommitMessageFromContextMock, + generatePullRequestFieldsFromContext: generatePullRequestFieldsFromContextMock, discoverCommitMessageModelsLocal: discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemote: discoverCommitMessageModelsRemoteMock, - cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock + cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock, + cancelGeneratePullRequestFieldsLocal: cancelGeneratePullRequestFieldsLocalMock })) import { registerFilesystemHandlers } from './filesystem' @@ -219,9 +225,11 @@ describe('registerFilesystemHandlers', () => { listWorktreesMock, resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, + generatePullRequestFieldsFromContextMock, discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, + cancelGeneratePullRequestFieldsLocalMock, getSshFilesystemProviderMock, getSshGitProviderMock ]) { @@ -1192,17 +1200,44 @@ describe('registerFilesystemHandlers', () => { await target.execute( { binary: 'agent', args: [], stdinPayload: null, label: 'agent' }, '/cwd', - 1 + 1, + 'commit-message' ) expect(executeCommitMessagePlan).toHaveBeenCalledWith( { binary: 'agent', args: [], stdinPayload: null, label: 'agent' }, '/cwd', - 1 + 1, + 'commit-message' ) expect(prepareForCodexLaunch).not.toHaveBeenCalled() expect(prepareForClaudeLaunch).not.toHaveBeenCalled() }) + it('routes SSH generation cancellations to separate provider operations', async () => { + const cancelGenerateCommitMessage = vi.fn().mockResolvedValue(undefined) + getSshGitProviderMock.mockReturnValue({ cancelGenerateCommitMessage }) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:cancelGenerateCommitMessage')!(null, { + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + await handlers.get('git:cancelGeneratePullRequestFields')!(null, { + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + + expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(1, '/remote/repo', 'commit-message') + expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith( + 2, + '/remote/repo', + 'pull-request-fields' + ) + expect(cancelGenerateCommitMessageLocalMock).not.toHaveBeenCalled() + expect(cancelGeneratePullRequestFieldsLocalMock).not.toHaveBeenCalled() + }) + it('does not call the generator when no staged changes exist', async () => { resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, @@ -1294,6 +1329,22 @@ describe('registerFilesystemHandlers', () => { expect(bulkDiscardChangesMock).not.toHaveBeenCalled() }) + it('routes ssh git:fastForward through the SSH provider', async () => { + const sshFastForwardMock = vi.fn().mockResolvedValue(undefined) + const pushTarget = { remoteName: 'fork', branchName: 'feature/fix' } + getSshGitProviderMock.mockReturnValue({ fastForwardBranch: sshFastForwardMock }) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:fastForward')!(null, { + worktreePath: '/remote/repo', + connectionId: 'conn-1', + pushTarget + }) + + expect(sshFastForwardMock).toHaveBeenCalledWith('/remote/repo', pushTarget) + }) + it('rejects git:commit with empty message and does not call commitChanges', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index c564f0a42ba..5fb43bd5c1f 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -64,7 +64,7 @@ import { } from '../text-generation/commit-message-text-generation' import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { getUpstreamStatus } from '../git/upstream' -import { gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' +import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' import { checkIgnoredPaths } from '../git/check-ignored-paths' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' @@ -823,8 +823,8 @@ export function registerFilesystemHandlers( return generateCommitMessageFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } @@ -865,7 +865,7 @@ export function registerFilesystemHandlers( if (!provider) { return } - await provider.cancelGenerateCommitMessage(args.worktreePath) + await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') return } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) @@ -967,8 +967,8 @@ export function registerFilesystemHandlers( return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } @@ -1017,7 +1017,7 @@ export function registerFilesystemHandlers( if (!provider) { return } - await provider.cancelGenerateCommitMessage(args.worktreePath) + await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') return } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) @@ -1169,6 +1169,30 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:fastForward', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget) + } + await gitFastForward(worktreePath, args.pushTarget) + } + ) + ipcMain.handle( 'git:rebaseFromBase', async ( diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 23c6afaa67c..2a5896dc0cc 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: GitHub IPC tests share one mocked Electron +handler harness; keeping the related route wiring together avoids duplicated setup. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -8,6 +10,11 @@ const { listWorkItemsMock, getAuthenticatedViewerMock, mergePRMock, + setPRAutoMergeMock, + checkOrcaStarredMock, + starOrcaMock, + trackMock, + getCohortAtEmitMock, getAllWebContentsMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -17,6 +24,11 @@ const { listWorkItemsMock: vi.fn(), getAuthenticatedViewerMock: vi.fn(), mergePRMock: vi.fn(), + setPRAutoMergeMock: vi.fn(), + checkOrcaStarredMock: vi.fn(), + starOrcaMock: vi.fn(), + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(), getAllWebContentsMock: vi.fn() })) @@ -35,7 +47,18 @@ vi.mock('../github/client', () => ({ listIssues: listIssuesMock, listWorkItems: listWorkItemsMock, getAuthenticatedViewer: getAuthenticatedViewerMock, - mergePR: mergePRMock + mergePR: mergePRMock, + setPRAutoMerge: setPRAutoMergeMock, + checkOrcaStarred: checkOrcaStarredMock, + starOrca: starOrcaMock +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock })) import { registerGitHubHandlers } from './github' @@ -70,6 +93,12 @@ describe('registerGitHubHandlers', () => { listWorkItemsMock.mockReset() getAuthenticatedViewerMock.mockReset() mergePRMock.mockReset() + setPRAutoMergeMock.mockReset() + checkOrcaStarredMock.mockReset() + starOrcaMock.mockReset() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: undefined }) getAllWebContentsMock.mockReset() getAllWebContentsMock.mockReturnValue([]) for (const key of Object.keys(handlers)) { @@ -259,6 +288,28 @@ describe('registerGitHubHandlers', () => { }) }) + it('threads SSH connectionId through pull request auto-merge', async () => { + repos[0].connectionId = 'openclaw-2' + setPRAutoMergeMock.mockResolvedValue({ ok: true }) + + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:setPRAutoMerge']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + enabled: true, + prRepo: { owner: 'acme', repo: 'orca' } + } + ) + + expect(setPRAutoMergeMock).toHaveBeenCalledWith('/workspace/repo', 42, true, 'openclaw-2', { + owner: 'acme', + repo: 'orca' + }) + }) + it('forwards the authenticated viewer lookup', async () => { getAuthenticatedViewerMock.mockResolvedValue({ login: 'octocat', email: 'octocat@example.com' }) @@ -270,4 +321,73 @@ describe('registerGitHubHandlers', () => { }) expect(getAuthenticatedViewerMock).toHaveBeenCalled() }) + + it('emits app_starred_orca once after a successful star with cohort context', async () => { + starOrcaMock.mockResolvedValue(true) + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 }) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:starOrca'](null, 'settings')).resolves.toBe(true) + + expect(starOrcaMock).toHaveBeenCalledTimes(1) + expect(getCohortAtEmitMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('app_starred_orca', { + source: 'settings', + nth_repo_added: 3 + }) + }) + + it('accepts every app star source for success telemetry', async () => { + starOrcaMock.mockResolvedValue(true) + + registerGitHubHandlers(store as never, stats as never) + + for (const source of ['star_nag', 'settings', 'landing'] as const) { + await expect(handlers['gh:starOrca'](null, source)).resolves.toBe(true) + } + + expect(trackMock).toHaveBeenCalledTimes(3) + expect(trackMock.mock.calls.map(([, props]) => props)).toEqual([ + { source: 'star_nag', nth_repo_added: undefined }, + { source: 'settings', nth_repo_added: undefined }, + { source: 'landing', nth_repo_added: undefined } + ]) + }) + + it('does not emit app_starred_orca when the star action returns false', async () => { + starOrcaMock.mockResolvedValue(false) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:starOrca'](null, 'landing')).resolves.toBe(false) + + expect(starOrcaMock).toHaveBeenCalledTimes(1) + expect(trackMock).not.toHaveBeenCalled() + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) + + it('does not emit app_starred_orca when the star action throws', async () => { + starOrcaMock.mockRejectedValue(new Error('gh failed')) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:starOrca'](null, 'star_nag')).rejects.toThrow('gh failed') + + expect(trackMock).not.toHaveBeenCalled() + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) + + it('preserves star result but skips telemetry for an invalid IPC source', async () => { + starOrcaMock.mockResolvedValue(true) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:starOrca'](null, 'github_website')).resolves.toBe(true) + + expect(starOrcaMock).toHaveBeenCalledTimes(1) + expect(trackMock).not.toHaveBeenCalled() + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 332cecb7dee..4a7fe5cb36c 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -39,6 +39,7 @@ import { addPRReviewCommentReply, updatePRTitle, mergePR, + setPRAutoMerge, updatePRState, rerunPRChecks, requestPRReviewers, @@ -92,6 +93,9 @@ import type { UpdateProjectItemFieldArgs, UpdatePullRequestBySlugArgs } from '../../shared/github-project-types' +import { appStarSourceSchema } from '../../shared/gh-star-source' +import { track } from '../telemetry/client' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' // Why: notify every renderer (each window has its own SWR cache instance) // that a work item was mutated locally so they can drop their cached entry @@ -680,6 +684,35 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:setPRAutoMerge', + async ( + event, + args: { + repoPath: string + prNumber: number + enabled: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredRepo(args, store) + const result = await setPRAutoMerge( + repo.path, + args.prNumber, + args.enabled, + repoConnectionId(repo), + args.prRepo ?? null + ) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + ipcMain.handle( 'gh:updatePRState', async ( @@ -844,7 +877,19 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi // Star operations target the Orca repo itself — no repoPath validation needed ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) - ipcMain.handle('gh:starOrca', () => starOrca()) + ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { + const sourceParse = appStarSourceSchema.safeParse(source) + const starred = await starOrca() + if (starred && sourceParse.success) { + // Why: this main-owned event bypasses renderer telemetry IPC, so cohort + // context must be attached here on the successful star path. + track('app_starred_orca', { + source: sourceParse.data, + ...getCohortAtEmit() + }) + } + return starred + }) // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so // polling is cheap. A 30s in-process cache still avoids the gh subprocess diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index d92179a8222..1213e8f1d92 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -6,6 +6,7 @@ const NOTIFICATION_BODY_PREVIEW_MAX_LENGTH = 180 const AGENT_TYPE_LABELS: Readonly> = { claude: 'Claude', + openclaude: 'OpenClaude', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 6f7a492cc2c..5aee3a6f956 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -8,6 +8,7 @@ const { execFileAsyncMock, hydrateShellPathMock, mergePathSegmentsMock, + getActiveMultiplexerMock, getBitbucketAuthStatusMock, getAzureDevOpsAuthStatusMock, getGiteaAuthStatusMock @@ -17,6 +18,7 @@ const { execFileAsyncMock: vi.fn(), hydrateShellPathMock: vi.fn(), mergePathSegmentsMock: vi.fn(), + getActiveMultiplexerMock: vi.fn(), getBitbucketAuthStatusMock: vi.fn(), getAzureDevOpsAuthStatusMock: vi.fn(), getGiteaAuthStatusMock: vi.fn() @@ -43,6 +45,10 @@ vi.mock('../startup/hydrate-shell-path', () => ({ mergePathSegments: mergePathSegmentsMock })) +vi.mock('./ssh', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock +})) + vi.mock('../bitbucket/client', () => ({ getBitbucketAuthStatus: getBitbucketAuthStatusMock })) @@ -62,7 +68,7 @@ import { runPreflightCheck } from './preflight' -type HandlerMap = Record Promise> +type HandlerMap = Record Promise> describe('preflight', () => { const originalPlatform = process.platform @@ -88,6 +94,7 @@ describe('preflight', () => { execFileAsyncMock.mockReset() hydrateShellPathMock.mockReset() mergePathSegmentsMock.mockReset() + getActiveMultiplexerMock.mockReset() getBitbucketAuthStatusMock.mockReset() getAzureDevOpsAuthStatusMock.mockReset() getGiteaAuthStatusMock.mockReset() @@ -348,6 +355,9 @@ describe('preflight', () => { if (command !== 'which') { throw new Error(`unexpected command ${String(command)}`) } + if (String(args[0]) === 'openclaude') { + return { stdout: '/Users/test/.local/bin/openclaude\n' } + } if (String(args[0]) === 'cursor-agent') { return { stdout: '/Users/test/.local/bin/cursor-agent\n' } } @@ -356,7 +366,24 @@ describe('preflight', () => { registerPreflightHandlers() - await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor']) + await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor']) + }) + + it('sends OpenClaude detection commands through the SSH remote preflight path', async () => { + const request = vi.fn().mockResolvedValue({ agents: ['openclaude'] }) + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => false, + request + }) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual(['openclaude']) + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: expect.arrayContaining([{ id: 'openclaude', cmd: 'openclaude' }]) + }) }) it('detects agents from the selected WSL distro for a WSL workspace', async () => { @@ -381,6 +408,30 @@ describe('preflight', () => { await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) }) + it('detects agents from the default WSL distro when requested', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[3]) + if (script === "command -v 'codex'") { + return { stdout: '/home/test/.local/bin/codex\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents({ wslDefault: true })).resolves.toEqual(['codex']) + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--', 'bash', '-lc', "command -v 'codex'"], + { encoding: 'utf-8', timeout: 5000 } + ) + }) + it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => { // Why: the Agents settings Refresh button calls this path. It must (1) ask // the shell hydrator for a fresh PATH, (2) merge any new segments, then diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index c38939a22be..8821a65ff48 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -14,6 +14,7 @@ const execFileAsync = promisify(execFile) type PreflightRuntimeContext = { wslDistro?: string | null + wslDefault?: boolean } export type PreflightStatus = { @@ -50,24 +51,32 @@ export function _resetPreflightCache(): void { cached = null } +type WslPreflightTarget = { + distro?: string +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } async function execCommandInWsl( - distro: string, + target: WslPreflightTarget, command: string ): Promise<{ stdout: string; stderr: string }> { - return execFileAsync('wsl.exe', ['-d', distro, '--', 'bash', '-lc', command], { + const distroArgs = target.distro ? ['-d', target.distro] : [] + return execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], { encoding: 'utf-8', timeout: 5000 }) as Promise<{ stdout: string; stderr: string }> } -async function isCommandAvailable(command: string, wslDistro?: string): Promise { +async function isCommandAvailable( + command: string, + wslTarget?: WslPreflightTarget +): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote(command)} --version`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) : execFileAsync(command, ['--version'])) return true } catch { @@ -78,11 +87,11 @@ async function isCommandAvailable(command: string, wslDistro?: string): Promise< // Why: `which`/`where` is faster than spawning the agent binary itself and avoids // triggering any agent-specific startup side-effects. This gives a reliable // PATH-based check without requiring `--version` support from each agent. -async function isCommandOnPath(command: string, wslDistro?: string): Promise { +async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise { const finder = process.platform === 'win32' ? 'where' : 'which' try { - const { stdout } = wslDistro - ? await execCommandInWsl(wslDistro, `command -v ${shellQuote(command)}`) + const { stdout } = wslTarget + ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) : await execFileAsync(finder, [command], { encoding: 'utf-8' }) return stdout .split(/\r?\n/) @@ -98,18 +107,26 @@ const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).map(([id, config]) cmd: config.detectCmd })) -function getPreflightWslDistro(context?: PreflightRuntimeContext): string | null { +function getPreflightWslTarget(context?: PreflightRuntimeContext): WslPreflightTarget | null { + if (process.platform !== 'win32') { + return null + } const distro = context?.wslDistro?.trim() - return process.platform === 'win32' && distro ? distro : null + if (distro) { + return { distro } + } + return context?.wslDefault ? {} : null } async function detectCommandRuntime( command: string, context?: PreflightRuntimeContext -): Promise<{ installed: boolean; wslDistro?: string }> { - const wslDistro = getPreflightWslDistro(context) - if (wslDistro && (await isCommandAvailable(command, wslDistro))) { - return { installed: true, wslDistro } +): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + return (await isCommandAvailable(command, wslTarget)) + ? { installed: true, wslTarget } + : { installed: false } } if (await isCommandAvailable(command)) { return { installed: true } @@ -118,12 +135,11 @@ async function detectCommandRuntime( } export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const wslDistro = getPreflightWslDistro(context) + const wslTarget = getPreflightWslTarget(context) const checks = await Promise.all( KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({ id, - installed: - (wslDistro ? await isCommandOnPath(cmd, wslDistro) : false) || (await isCommandOnPath(cmd)) + installed: await isCommandOnPath(cmd, wslTarget ?? undefined) })) ) return checks.filter((c) => c.installed).map((c) => c.id) @@ -178,10 +194,10 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis return result.agents } -async function isGhAuthenticated(wslDistro?: string): Promise { +async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote('gh')} auth status`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`) : execFileAsync('gh', ['auth', 'status'], { encoding: 'utf-8' })) @@ -201,10 +217,10 @@ async function isGhAuthenticated(wslDistro?: string): Promise { // Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth // status to stderr in some versions and stdout in others; check both. -async function isGlabAuthenticated(wslDistro?: string): Promise { +async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise { try { - await (wslDistro - ? execCommandInWsl(wslDistro, `${shellQuote('glab')} auth status`) + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`) : execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' })) return true } catch (error) { @@ -219,7 +235,7 @@ export async function runPreflightCheck( force = false, context?: PreflightRuntimeContext ): Promise { - const cacheable = !getPreflightWslDistro(context) + const cacheable = !getPreflightWslTarget(context) if (cacheable && cached && !force) { return cached } @@ -241,8 +257,8 @@ export async function runPreflightCheck( ]) const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ - ghProbe.installed ? isGhAuthenticated(ghProbe.wslDistro) : Promise.resolve(false), - glabProbe.installed ? isGlabAuthenticated(glabProbe.wslDistro) : Promise.resolve(false), + ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false), + glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false), getBitbucketAuthStatus(), getAzureDevOpsAuthStatus(), getGiteaAuthStatus() diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 3fc08a71d38..76ef18597c5 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2894,6 +2894,158 @@ describe('registerPtyHandlers', () => { } }) + it('holds background PTY output briefly after foreground input', async () => { + vi.useFakeTimers() + const activeProc = createMockProc() + const backgroundProc = createMockProc() + spawnMock.mockReturnValueOnce(activeProc.proc).mockReturnValueOnce(backgroundProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const activeSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const backgroundSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + mainWindow.webContents.send.mockClear() + + backgroundProc.emitData('background output') + writeListener(null, { + id: activeSpawn.id, + data: 'a' + }) + + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + + activeProc.emitData('\x1b[20;2Hredraw') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: activeSpawn.id, + data: '\x1b[20;2Hredraw' + }) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + + vi.advanceTimersByTime(41) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + + vi.advanceTimersByTime(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not hold foreground PTY batch output behind background output', async () => { + vi.useFakeTimers() + const activeProc = createMockProc() + const backgroundProc = createMockProc() + spawnMock.mockReturnValueOnce(activeProc.proc).mockReturnValueOnce(backgroundProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const activeSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const backgroundSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + mainWindow.webContents.send.mockClear() + + backgroundProc.emitData('background output') + writeListener(null, { + id: activeSpawn.id, + data: 'a' + }) + const foregroundOutput = 'x'.repeat(1025) + activeProc.emitData(foregroundOutput) + + vi.advanceTimersByTime(8) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: activeSpawn.id, + data: foregroundOutput + }) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not starve background PTY output during continuous input', async () => { + vi.useFakeTimers() + const activeProc = createMockProc() + const backgroundProc = createMockProc() + spawnMock.mockReturnValueOnce(activeProc.proc).mockReturnValueOnce(backgroundProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const activeSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const backgroundSpawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + mainWindow.webContents.send.mockClear() + + backgroundProc.emitData('background output') + for (let index = 0; index < 7; index++) { + writeListener(null, { + id: activeSpawn.id, + data: 'a' + }) + vi.advanceTimersByTime(40) + } + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + + writeListener(null, { + id: activeSpawn.id, + data: 'a' + }) + vi.advanceTimersByTime(10) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: backgroundSpawn.id, + data: 'background output' + }) + } finally { + vi.useRealTimers() + } + }) + it('ignores PTY input for unknown sessions', async () => { vi.useFakeTimers() const mockProc = createMockProc() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c48a5ff8ede..33b7880e3ce 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -24,6 +24,7 @@ import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import { isClaudeAuthSwitchInProgress, @@ -56,6 +57,8 @@ import { } from '../agent-hooks/migration-unsupported-pty-state' import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -286,6 +289,38 @@ function shouldSkipCodexHomeEnvForWindowsShell( } const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const +type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null +type PrepareClaudeAuth = ( + target?: ClaudeAccountSelectionTarget +) => Promise + +function getCodexSelectionTargetForPty( + shellPath: string | undefined, + cwd: string | undefined, + wslDistro?: string | null +): CodexAccountSelectionTarget { + const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null + if (isWslShellName(shellPath) || wslPath) { + return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } + } + return { runtime: 'host' } +} + +function getCompatibleSelectedCodexHomePath( + target: CodexAccountSelectionTarget, + selectedCodexHomePath: string | null +): string | null { + if (!selectedCodexHomePath) { + return null + } + const wslInfo = parseWslPath(selectedCodexHomePath) + if (target.runtime === 'wsl') { + return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null + } + return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) + ? null + : selectedCodexHomePath +} function readEnvWithProcessFallback( baseEnv: Record, @@ -795,9 +830,9 @@ export function unbindLocalProviderListeners(): void { export function registerPtyHandlers( mainWindow: BrowserWindow, runtime?: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, - prepareClaudeAuth?: () => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, store?: Store ): void { // Remove any previously registered handlers so we can re-register them @@ -832,13 +867,19 @@ export function registerPtyHandlers( : undefined, pwshAvailable: () => isPwshAvailable(), buildSpawnEnv: (id, baseEnv, ctx) => { + const codexSelectionTarget: CodexAccountSelectionTarget = + ctx?.isWsl === true + ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } + : { runtime: 'host' } + const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) const env = buildPtyHostEnv(id, baseEnv, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - // Why: WSL's inner shell cannot use a Windows userData CODEX_HOME. - // Leave Linux Codex on its native ~/.codex until we own a WSL home. - skipCodexHomeEnv: ctx?.isWsl === true, + selectedCodexHomePath, + skipCodexHomeEnv: ctx?.isWsl === true && !selectedCodexHomePath, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: ctx?.command, agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) @@ -889,6 +930,11 @@ export function registerPtyHandlers( // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + const BACKGROUND_OUTPUT_INPUT_QUIET_MS = 50 + const BACKGROUND_OUTPUT_MAX_INPUT_HOLD_MS = 250 + let lastRendererInputAt = Number.NEGATIVE_INFINITY + let lastRendererInputPtyId: string | null = null + let backgroundFlushHeldSince: number | null = null function getChunkStartSeq(endSeq: number | undefined, data: string): number | undefined { return typeof endSeq === 'number' ? Math.max(0, endSeq - data.length) : undefined @@ -931,15 +977,32 @@ export function registerPtyHandlers( flushTimer = setTimeout(flushPendingData, delayMs) } - function flushPendingData(): void { - flushTimer = null - if (mainWindow.isDestroyed()) { - pendingData.clear() - return + function hasRecentRendererInput(now: number): boolean { + return now - lastRendererInputAt < BACKGROUND_OUTPUT_INPUT_QUIET_MS + } + + function hasPendingDataOutsideRecentInputPty(): boolean { + for (const id of pendingData.keys()) { + if (id !== lastRendererInputPtyId) { + return true + } } + return false + } + + function drainPendingDataEntries( + maxWrites: number, + shouldDrain: (id: string) => boolean = () => true + ): number { let writes = 0 - while (pendingData.size > 0 && writes < PTY_BATCH_FLUSH_MAX_WRITES) { - const next = pendingData.entries().next().value + while (writes < maxWrites) { + let next: [string, PendingPtyData] | undefined + for (const entry of pendingData.entries()) { + if (shouldDrain(entry[0])) { + next = entry + break + } + } if (!next) { break } @@ -958,6 +1021,51 @@ export function registerPtyHandlers( mainWindow.webContents.send('pty:data', makePtyDataPayload(id, chunk, pending.startSeq)) writes++ } + return writes + } + + function shouldHoldBackgroundFlushForInput(now: number): boolean { + if ( + pendingData.size === 0 || + !hasRecentRendererInput(now) || + !hasPendingDataOutsideRecentInputPty() + ) { + backgroundFlushHeldSince = null + return false + } + backgroundFlushHeldSince ??= now + if (now - backgroundFlushHeldSince >= BACKGROUND_OUTPUT_MAX_INPUT_HOLD_MS) { + backgroundFlushHeldSince = null + return false + } + return true + } + + function flushPendingData(): void { + flushTimer = null + if (mainWindow.isDestroyed()) { + pendingData.clear() + return + } + const now = performance.now() + let writes = 0 + if (hasRecentRendererInput(now) && lastRendererInputPtyId !== null) { + writes += drainPendingDataEntries( + PTY_BATCH_FLUSH_MAX_WRITES, + (id) => id === lastRendererInputPtyId + ) + } + if (shouldHoldBackgroundFlushForInput(now)) { + // Why: hidden PTYs can keep producing output while the user types in a + // foreground TUI. Holding background IPC briefly lets those bytes + // coalesce instead of flooding the renderer ahead of key echo frames. + const quietDelay = Math.max(1, BACKGROUND_OUTPUT_INPUT_QUIET_MS - (now - lastRendererInputAt)) + schedulePendingDataFlush(quietDelay) + return + } + if (writes < PTY_BATCH_FLUSH_MAX_WRITES) { + writes += drainPendingDataEntries(PTY_BATCH_FLUSH_MAX_WRITES - writes) + } if (pendingData.size > 0) { // Why: a background terminal can dump megabytes at once. Yield between // small IPC slices so keystroke writes are not stuck behind one flush. @@ -1052,6 +1160,9 @@ export function registerPtyHandlers( pendingData.delete(payload.id) } lastInputAtByPty.delete(payload.id) + if (lastRendererInputPtyId === payload.id) { + lastRendererInputPtyId = null + } mainWindow.webContents.send('pty:exit', payload) } }) @@ -1177,7 +1288,17 @@ export function registerPtyHandlers( if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } - const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + const daemonShellOverride = + process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined + const codexSelectionTarget = getCodexSelectionTargetForPty( + daemonShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const claudeAuth = + isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -1195,12 +1316,16 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } - const daemonShellOverride = - process.platform === 'win32' && !args.connectionId - ? getSettings?.()?.terminalWindowsShell - : undefined + const selectedCodexHomePath = isDaemonHostSpawn + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) + : null const skipCodexHomeEnv = - isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) + isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) && + !selectedCodexHomePath if (isDaemonHostSpawn && sessionId) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { throw new Error('Invalid PTY session id') @@ -1208,7 +1333,7 @@ export function registerPtyHandlers( env = buildPtyHostEnv(sessionId, env ?? {}, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + selectedCodexHomePath, skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, @@ -1247,6 +1372,7 @@ export function registerPtyHandlers( } if (process.platform === 'win32' && !args.connectionId) { spawnOptions.shellOverride = getSettings?.()?.terminalWindowsShell + spawnOptions.terminalWindowsWslDistro = getSettings?.()?.terminalWindowsWslDistro ?? null spawnOptions.terminalWindowsPowerShellImplementation = getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined @@ -1474,7 +1600,18 @@ export function registerPtyHandlers( if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } - const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + const initialShellOverride = + args.shellOverride ?? + (process.platform === 'win32' && !args.connectionId + ? getSettings?.()?.terminalWindowsShell + : undefined) + const initialSelectionTarget = getCodexSelectionTargetForPty( + initialShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const claudeAuth = + isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -1602,8 +1739,21 @@ export function registerPtyHandlers( (process.platform === 'win32' && !args.connectionId ? getSettings?.()?.terminalWindowsShell : undefined) + const codexSelectionTarget = getCodexSelectionTargetForPty( + effectiveShellOverride, + args.cwd, + getSettings?.()?.terminalWindowsWslDistro ?? null + ) + const selectedCodexHomePath = isDaemonHostSpawn + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + ) + : null const skipCodexHomeEnv = - isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) + isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) && + !selectedCodexHomePath if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when @@ -1627,7 +1777,7 @@ export function registerPtyHandlers( buildPtyHostEnv(sessionIdForEnv, env, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), - selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + selectedCodexHomePath, skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, @@ -1704,6 +1854,7 @@ export function registerPtyHandlers( // the persisted implementation choice through spawnOptions so both the // in-process and daemon-backed PTY paths can resolve the same effective // executable without inventing a fourth top-level shell. + spawnOptions.terminalWindowsWslDistro = getSettings?.()?.terminalWindowsWslDistro ?? null spawnOptions.terminalWindowsPowerShellImplementation = getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined @@ -1984,7 +2135,10 @@ export function registerPtyHandlers( return false } try { - lastInputAtByPty.set(args.id, performance.now()) + const now = performance.now() + lastRendererInputAt = now + lastRendererInputPtyId = args.id + lastInputAtByPty.set(args.id, now) provider.write(args.id, args.data) return true } catch { @@ -2008,7 +2162,10 @@ export function registerPtyHandlers( return false } try { - lastInputAtByPty.set(args.id, performance.now()) + const now = performance.now() + lastRendererInputAt = now + lastRendererInputPtyId = args.id + lastInputAtByPty.set(args.id, now) provider.write(args.id, args.data) return true } catch { @@ -2217,9 +2374,9 @@ export function registerPtyHandlers( export function registerHeadlessPtyRuntime( runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, - prepareClaudeAuth?: () => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, store?: Store ): void { // Why: headless `orca serve` has no renderer window, but the runtime still diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 703e9761721..63a50649468 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -1,9 +1,16 @@ import { ipcMain } from 'electron' import type { RateLimitService } from '../rate-limits/service' +import type { RateLimitRuntimeTarget } from '../../shared/rate-limit-types' export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:get', () => rateLimits.getState()) ipcMain.handle('rateLimits:refresh', () => rateLimits.refresh()) + ipcMain.handle('rateLimits:refreshCodexForTarget', (_event, target: RateLimitRuntimeTarget) => + rateLimits.refreshCodexForTarget(target) + ) + ipcMain.handle('rateLimits:refreshClaudeForTarget', (_event, target: RateLimitRuntimeTarget) => + rateLimits.refreshClaudeForTarget(target) + ) ipcMain.handle('rateLimits:setPollingInterval', (_event, ms: number) => rateLimits.setPollingInterval(ms) ) diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts new file mode 100644 index 00000000000..b37725500ee --- /dev/null +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -0,0 +1,325 @@ +/* oxlint-disable max-lines -- Why: queue/cache regression cases share one mocked IPC harness so stale revision sequencing stays visible. */ +import { ipcMain } from 'electron' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { + RemoteWorkspaceSession, + RemoteWorkspaceSnapshot +} from '../../shared/remote-workspace-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/types' + +const { + getActiveMultiplexerMock, + getSshConnectionStoreMock, + registerRemoteWorkspaceNotificationHandlerMock +} = vi.hoisted(() => ({ + getActiveMultiplexerMock: vi.fn(), + getSshConnectionStoreMock: vi.fn(), + registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock, + getSshConnectionStore: getSshConnectionStoreMock +})) + +vi.mock('./remote-workspace-events', () => ({ + registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock +})) + +import { registerRemoteWorkspaceHandlers } from './remote-workspace' + +function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { + return { + namespace: 'target', + revision, + updatedAt: 123, + schemaVersion: 1, + session + } +} + +function sessionWithTab(worktreeId: string, tabId: string): WorkspaceSessionState { + return { + activeRepoId: null, + activeWorktreeId: worktreeId, + activeTabId: tabId, + tabsByWorktree: { + [worktreeId]: [{ id: tabId, type: 'terminal', title: 'Shell', worktreeId } as never] + }, + terminalLayoutsByTabId: {} + } +} + +function patchSession(params: Record): RemoteWorkspaceSession { + return (params.patch as { session: RemoteWorkspaceSession }).session +} + +describe('remoteWorkspace:setForConnectedTargets patch queue', () => { + const handlers = new Map unknown>() + const muxByTargetId = new Map }>() + const getRepoMock = vi.fn() + const store = { + getRepo: getRepoMock + } as unknown as Store + + const target: SshTarget = { + id: 'target-1', + label: 'Target 1', + host: 'one.example.com', + port: 22, + username: 'alice' + } + + beforeEach(() => { + handlers.clear() + muxByTargetId.clear() + vi.mocked(ipcMain.handle).mockReset() + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) + }) + vi.mocked(ipcMain.removeHandler).mockReset() + getSshConnectionStoreMock.mockReset() + getSshConnectionStoreMock.mockReturnValue({ + listTargets: () => [target] + }) + getRepoMock.mockReset() + getRepoMock.mockImplementation((repoId: string) => + repoId === 'repo-target-1' + ? ({ + id: 'repo-target-1', + path: '/remote/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'target-1' + } as never) + : undefined + ) + getActiveMultiplexerMock.mockReset() + getActiveMultiplexerMock.mockImplementation((targetId: string) => muxByTargetId.get(targetId)) + registerRemoteWorkspaceNotificationHandlerMock.mockClear() + + registerRemoteWorkspaceHandlers(store, () => null) + }) + + async function callSetForConnectedTargets(args: { + session: WorkspaceSessionState + hydratedTargetIds?: unknown + }): Promise { + const handler = handlers.get('remoteWorkspace:setForConnectedTargets') + if (!handler) { + throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') + } + return handler(null, args) + } + + it('serializes overlapping writes for the same target so they use fresh base revisions', async () => { + let currentRevision = 7 + let releaseFirstPatch!: () => void + const firstPatchCanFinish = new Promise((resolve) => { + releaseFirstPatch = resolve + }) + const patchBaseRevisions: number[] = [] + const request = vi + .fn() + .mockImplementation(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot( + { + activeWorktreePath: '/previous', + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + currentRevision + ) + } + if (method === 'workspace.patch') { + patchBaseRevisions.push(params.baseRevision as number) + if (patchBaseRevisions.length === 1) { + await firstPatchCanFinish + } + currentRevision += 1 + return { + ok: true, + snapshot: snapshot(patchSession(params), currentRevision) + } + } + throw new Error(`Unexpected method ${method}`) + }) + muxByTargetId.set('target-1', { request }) + + const first = callSetForConnectedTargets({ + session: sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), + hydratedTargetIds: ['target-1'] + }) + await vi.waitFor(() => expect(patchBaseRevisions).toEqual([7])) + + const second = callSetForConnectedTargets({ + session: sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b'), + hydratedTargetIds: ['target-1'] + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchBaseRevisions).toEqual([7]) + + releaseFirstPatch() + await expect(Promise.all([first, second])).resolves.toMatchObject([ + [{ targetId: 'target-1', result: { ok: true } }], + [{ targetId: 'target-1', result: { ok: true } }] + ]) + expect(patchBaseRevisions).toEqual([7, 8]) + }) + + it('retries once when a reset relay reports a lower revision than the cached base', async () => { + const resetTarget: SshTarget = { + id: 'target-reset', + label: 'Reset Target', + host: 'reset.example.com', + port: 22, + username: 'alice' + } + getSshConnectionStoreMock.mockReturnValue({ + listTargets: () => [resetTarget] + }) + getRepoMock.mockImplementation((repoId: string) => + repoId === 'repo-reset' + ? ({ + id: 'repo-reset', + path: '/remote/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'target-reset' + } as never) + : undefined + ) + + const patchBaseRevisions: number[] = [] + const request = vi + .fn() + .mockImplementation(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot( + { + activeWorktreePath: '/previous', + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + 7 + ) + } + if (method === 'workspace.patch') { + patchBaseRevisions.push(params.baseRevision as number) + if (patchBaseRevisions.length === 1) { + return { + ok: false, + reason: 'stale-revision', + snapshot: snapshot( + { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + 0 + ) + } + } + return { + ok: true, + snapshot: snapshot(patchSession(params), 1) + } + } + throw new Error(`Unexpected method ${method}`) + }) + muxByTargetId.set('target-reset', { request }) + + await expect( + callSetForConnectedTargets({ + session: sessionWithTab('repo-reset::/remote/workspace', 'tab-reset'), + hydratedTargetIds: ['target-reset'] + }) + ).resolves.toMatchObject([{ targetId: 'target-reset', result: { ok: true } }]) + expect(patchBaseRevisions).toEqual([7, 0]) + }) + + it('does not retry stale writes when the relay reports a newer revision', async () => { + const newerTarget: SshTarget = { + id: 'target-newer', + label: 'Newer Target', + host: 'newer.example.com', + port: 22, + username: 'alice' + } + getSshConnectionStoreMock.mockReturnValue({ + listTargets: () => [newerTarget] + }) + getRepoMock.mockImplementation((repoId: string) => + repoId === 'repo-newer' + ? ({ + id: 'repo-newer', + path: '/remote/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'target-newer' + } as never) + : undefined + ) + + const patchBaseRevisions: number[] = [] + const request = vi + .fn() + .mockImplementation(async (method: string, params: Record) => { + if (method === 'workspace.get') { + return snapshot( + { + activeWorktreePath: '/previous', + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + 7 + ) + } + if (method === 'workspace.patch') { + patchBaseRevisions.push(params.baseRevision as number) + return { + ok: false, + reason: 'stale-revision', + snapshot: snapshot( + { + activeWorktreePath: '/other-device', + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + 8 + ) + } + } + throw new Error(`Unexpected method ${method}`) + }) + muxByTargetId.set('target-newer', { request }) + + await expect( + callSetForConnectedTargets({ + session: sessionWithTab('repo-newer::/remote/workspace', 'tab-local'), + hydratedTargetIds: ['target-newer'] + }) + ).resolves.toMatchObject([ + { targetId: 'target-newer', result: { ok: false, reason: 'stale-revision' } } + ]) + expect(patchBaseRevisions).toEqual([7]) + }) +}) diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 1e9ca37441f..46d249b35c6 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -39,10 +39,10 @@ import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace' -function snapshot(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot { +function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { return { namespace: 'target', - revision: 7, + revision, updatedAt: 123, schemaVersion: 1, session @@ -50,6 +50,7 @@ function snapshot(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot { } const baseSession = { + activeRepoId: null, activeWorktreeId: null, activeTabId: null, tabsByWorktree: {}, @@ -148,8 +149,9 @@ describe('remoteWorkspace:setForConnectedTargets', () => { const handlers = new Map unknown>() const requestByTargetId = new Map>() const muxByTargetId = new Map }>() + const getRepoMock = vi.fn() const store = { - getRepo: vi.fn() + getRepo: getRepoMock } as unknown as Store beforeEach(() => { @@ -165,6 +167,19 @@ describe('remoteWorkspace:setForConnectedTargets', () => { getSshConnectionStoreMock.mockReturnValue({ listTargets: () => targets }) + getRepoMock.mockReset() + getRepoMock.mockImplementation((repoId: string) => + repoId === 'repo-target-1' + ? ({ + id: 'repo-target-1', + path: '/remote/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'target-1' + } as never) + : undefined + ) getActiveMultiplexerMock.mockReset() getActiveMultiplexerMock.mockImplementation((targetId: string) => { let mux = muxByTargetId.get(targetId) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 5f3a6cc0355..77173c249fd 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -28,6 +28,7 @@ const SNAPSHOT_SCHEMA_VERSION = 1 let mainWindowGetter: (() => BrowserWindow | null) | null = null const latestSnapshotByTargetId = new Map() +const remoteWorkspacePatchTailByTargetId = new Map>() let unregisterRemoteWorkspaceNotifications: (() => void) | null = null function emptyRemoteSession(): RemoteWorkspaceSession { @@ -220,6 +221,106 @@ async function getRemoteSnapshot(target: SshTarget): Promise( + targetId: string, + operation: () => Promise +): Promise { + const previous = remoteWorkspacePatchTailByTargetId.get(targetId) ?? Promise.resolve() + let release!: () => void + const tail = new Promise((resolve) => { + release = resolve + }) + const queued = previous.catch(() => {}).then(() => tail) + remoteWorkspacePatchTailByTargetId.set(targetId, queued) + + await previous.catch(() => {}) + try { + return await operation() + } finally { + release() + if (remoteWorkspacePatchTailByTargetId.get(targetId) === queued) { + remoteWorkspacePatchTailByTargetId.delete(targetId) + } + } +} + +async function patchRemoteWorkspaceSession( + target: SshTarget, + session: RemoteWorkspaceSession +): Promise { + const mux = getActiveMultiplexer(target.id) + if (!mux) { + return null + } + const namespace = getRemoteWorkspaceNamespace(target) + const current = + latestSnapshotByTargetId.get(target.id) ?? (await getRemoteSnapshot(target)) ?? undefined + if (current && remoteWorkspaceSessionMatchesSnapshot(current, session)) { + // Why: a pulled workspace snapshot rehydrates local state and can trigger + // session persistence. Identical target sessions must stay a local no-op or + // two clients will echo revisions indefinitely. + return { ok: true, snapshot: current } + } + + const requestPatch = async ( + baseRevision: number | undefined + ): Promise => { + try { + return (await mux.request('workspace.patch', { + namespace, + baseRevision: baseRevision ?? 0, + clientId: CLIENT_ID, + patch: { kind: 'replace-session', session } + })) as RemoteWorkspacePatchResult + } catch (err) { + return (err as { code?: unknown })?.code === -32601 + ? { + ok: false, + reason: 'unavailable', + message: 'Remote workspace sync is unavailable on this relay' + } + : { + ok: false, + reason: 'unavailable', + message: err instanceof Error ? err.message : 'Remote workspace sync failed' + } + } + } + + const result = await requestPatch(current?.revision) + if (result.ok) { + latestSnapshotByTargetId.set(target.id, result.snapshot) + return result + } + if (result.snapshot) { + latestSnapshotByTargetId.set(target.id, result.snapshot) + } + + if ( + result.reason === 'stale-revision' && + current && + result.snapshot && + result.snapshot.revision < current.revision + ) { + if (remoteWorkspaceSessionMatchesSnapshot(result.snapshot, session)) { + return { ok: true, snapshot: result.snapshot } + } + // Why: a relay reset can legitimately move the remote snapshot revision + // backwards while this process still has the old cached revision. Retrying + // only for backwards revisions restores the blank-slate target without + // overwriting a newer snapshot from another device. + const retry = await requestPatch(result.snapshot.revision) + if (retry.ok) { + latestSnapshotByTargetId.set(target.id, retry.snapshot) + } else if (retry.snapshot) { + latestSnapshotByTargetId.set(target.id, retry.snapshot) + } + return retry + } + + return result +} + export function handleRemoteWorkspaceNotification( targetId: string, method: string, @@ -287,48 +388,13 @@ export function registerRemoteWorkspaceHandlers( const results: { targetId: string; result: RemoteWorkspacePatchResult }[] = [] for (const target of targets) { - const mux = getActiveMultiplexer(target.id) - if (!mux) { - continue - } - const namespace = getRemoteWorkspaceNamespace(target) - const current = - latestSnapshotByTargetId.get(target.id) ?? (await getRemoteSnapshot(target)) ?? undefined const session = exportSessionForTarget(store, target.id, args.session) - let result: RemoteWorkspacePatchResult - if (current && remoteWorkspaceSessionMatchesSnapshot(current, session)) { - // Why: a pulled workspace snapshot rehydrates local state and can - // trigger session persistence. Identical target sessions must be a - // local no-op or two clients will echo revisions indefinitely. - result = { ok: true, snapshot: current } + const result = await queueRemoteWorkspacePatch(target.id, () => + patchRemoteWorkspaceSession(target, session) + ) + if (result) { results.push({ targetId: target.id, result }) - continue } - try { - result = (await mux.request('workspace.patch', { - namespace, - baseRevision: current?.revision ?? 0, - clientId: CLIENT_ID, - patch: { kind: 'replace-session', session } - })) as RemoteWorkspacePatchResult - } catch (err) { - result = - (err as { code?: unknown })?.code === -32601 - ? { - ok: false, - reason: 'unavailable', - message: 'Remote workspace sync is unavailable on this relay' - } - : { - ok: false, - reason: 'unavailable', - message: err instanceof Error ? err.message : 'Remote workspace sync failed' - } - } - if (result.ok) { - latestSnapshotByTargetId.set(target.id, result.snapshot) - } - results.push({ targetId: target.id, result }) } return results } diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index da6b5c79f92..8af78912ad3 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -1332,7 +1332,17 @@ describe('repos:searchBaseRefs SSH relay', () => { expect(mockGitProvider.exec).not.toHaveBeenCalled() }) - it('short-circuits an empty query without calling the relay', async () => { + it('returns refs for an empty query so remote Branch pickers open populated', async () => { + const stdout = [ + 'refs/remotes/origin/main\0origin/main', + 'refs/remotes/upstream/feature-x\0upstream/feature-x' + ].join('\n') + mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => { + if (argv[0] === 'remote') { + return Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) + } + return Promise.resolve({ stdout, stderr: '' }) + }) mockStore.getRepo.mockReturnValue({ id: 'r1', path: '/remote/repo', @@ -1345,14 +1355,47 @@ describe('repos:searchBaseRefs SSH relay', () => { query: '' }) - // Why: empty-query short-circuit must happen in the handler (mirrors the - // local path's normalizeRefSearchQuery guard). Without it a user-typed - // empty query would leak every ref from the remote. - expect(result).toEqual([]) - expect(mockGitProvider.exec).not.toHaveBeenCalled() + expect(result).toEqual(['origin/main', 'upstream/feature-x']) + expect(mockGitProvider.exec).toHaveBeenCalledTimes(2) + const [argv] = mockGitProvider.exec.mock.calls.find( + (call) => (call[0] as string[])[0] === 'for-each-ref' + )! + expect(argv).toContain('--exclude=refs/remotes/**/HEAD') + expect(argv).toContain('--count=100') + expect(argv).toContain('refs/heads/**/**') + expect(argv).toContain('refs/heads/**/**/**') + expect(argv).toContain('refs/remotes/**/**') + expect(argv).toContain('refs/remotes/**/**/**') }) - it('short-circuits a query containing only glob metacharacters', async () => { + it('sanitizes glob metacharacter-only queries into the empty-query branch list', async () => { + mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + mockStore.getRepo.mockReturnValue({ + id: 'r1', + path: '/remote/repo', + connectionId: 'conn-1', + kind: 'git' + }) + + await handlers.get('repos:searchBaseRefs')!(null, { + repoId: 'r1', + query: '***' + }) + + // Why: normalizeRefSearchQuery still strips glob metacharacters before + // building argv; the resulting empty query now intentionally lists refs. + const [argv] = mockGitProvider.exec.mock.calls.find( + (call) => (call[0] as string[])[0] === 'for-each-ref' + )! + expect(argv).toContain('--exclude=refs/remotes/**/HEAD') + expect(argv).toContain('--count=100') + expect(argv).toContain('refs/heads/**/**') + expect(argv).toContain('refs/heads/**/**/**') + expect(argv).toContain('refs/remotes/**/**') + expect(argv).toContain('refs/remotes/**/**/**') + }) + + it('rejects invalid limits before building broad relay searches', async () => { mockStore.getRepo.mockReturnValue({ id: 'r1', path: '/remote/repo', @@ -1362,17 +1405,55 @@ describe('repos:searchBaseRefs SSH relay', () => { const result = await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', - query: '***' + query: '', + limit: 0.5 }) - // Why: normalizeRefSearchQuery strips `*?[]\`, so a query made up only of - // glob metacharacters normalizes to '' and must be treated like an empty - // query (no relay call, no leaked refs). Guards against glob injection - // via the SSH branch. expect(result).toEqual([]) expect(mockGitProvider.exec).not.toHaveBeenCalled() }) + it('retries without --exclude for older git on SSH hosts', async () => { + const stdout = [ + 'refs/remotes/origin/main\0origin/main', + 'refs/remotes/origin/HEAD\0origin/HEAD' + ].join('\n') + mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => { + if (argv[0] === 'remote') { + return Promise.resolve({ stdout: 'origin\n', stderr: '' }) + } + if (argv.includes('--exclude=refs/remotes/**/HEAD')) { + return Promise.reject( + Object.assign(new Error("unknown option `exclude'"), { + stderr: "error: unknown option `exclude'" + }) + ) + } + return Promise.resolve({ stdout, stderr: '' }) + }) + mockStore.getRepo.mockReturnValue({ + id: 'r1', + path: '/remote/repo', + connectionId: 'conn-1', + kind: 'git' + }) + + const result = await handlers.get('repos:searchBaseRefs')!(null, { + repoId: 'r1', + query: '', + limit: 1 + }) + + expect(result).toEqual(['origin/main']) + const forEachRefCalls = mockGitProvider.exec.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'for-each-ref' + ) + expect(forEachRefCalls).toHaveLength(2) + expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD') + expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD') + expect(forEachRefCalls[1][0]).toContain('--count=104') + }) + it('sends the widened `**` argv so all remotes and slash-named branches are discoverable', async () => { // Why: this is the core issue-624 behavior — the SSH path must glob all // remotes, not just origin. The `**` globs additionally span ref segments @@ -1390,7 +1471,9 @@ describe('repos:searchBaseRefs SSH relay', () => { await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream' }) expect(mockGitProvider.exec).toHaveBeenCalledTimes(2) - const [argv, path] = mockGitProvider.exec.mock.calls[0] + const [argv, path] = mockGitProvider.exec.mock.calls.find( + (call) => (call[0] as string[])[0] === 'for-each-ref' + )! expect(path).toBe('/remote/repo') expect(argv[0]).toBe('for-each-ref') expect(argv).toContain('refs/heads/**/*upstream*') @@ -1399,7 +1482,7 @@ describe('repos:searchBaseRefs SSH relay', () => { expect(argv).toContain('refs/remotes/**/*upstream*/**') // Guard against regression to the old origin-only glob. expect(argv).not.toContain('refs/remotes/origin/*upstream*') - expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo']) + expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo') }) it('sends segmented argv for display-format queries like `upstream/main`', async () => { @@ -1420,7 +1503,9 @@ describe('repos:searchBaseRefs SSH relay', () => { await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream/main' }) expect(mockGitProvider.exec).toHaveBeenCalledTimes(2) - const [argv] = mockGitProvider.exec.mock.calls[0] + const [argv] = mockGitProvider.exec.mock.calls.find( + (call) => (call[0] as string[])[0] === 'for-each-ref' + )! expect(argv).toContain('refs/remotes/*upstream*/*main*') expect(argv).toContain('refs/heads/*upstream*/*main*') // Regression guard: the literal slash must never appear inside a @@ -1428,7 +1513,7 @@ describe('repos:searchBaseRefs SSH relay', () => { // which fnmatch cannot match because `*` doesn't cross `/`. expect(argv).not.toContain('refs/remotes/*upstream/main*/*') expect(argv).not.toContain('refs/remotes/*/*upstream/main*') - expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo']) + expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo') }) it('parses NUL-delimited stdout and filters /HEAD pseudo-refs', async () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 66ec75eaf9f..148c00e4b84 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -50,6 +50,7 @@ import { parseRemoteCount, resolveDefaultBaseRefViaExec, buildSearchBaseRefsArgv, + isForEachRefExcludeUnsupportedError, searchBaseRefDetails } from '../git/repo' import { getSshGitProvider } from '../providers/ssh-git-dispatch' @@ -1385,6 +1386,9 @@ async function searchBaseRefDetailsForRepo( return [] } const limit = args.limit ?? 25 + if (!Number.isInteger(limit) || limit <= 0) { + return [] + } // Why: remote repos need the relay to list branches on the remote host. if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) @@ -1393,20 +1397,25 @@ async function searchBaseRefDetailsForRepo( } // Why: mirror the local path's sanitization (normalizeRefSearchQuery // in ../git/repo.ts) — strip glob metacharacters to prevent glob - // injection via the SSH branch, and short-circuit empty queries so - // we don't leak every ref. Without this the SSH path diverges from - // the local path's behavior. + // injection via the SSH branch while preserving empty-query branch lists. const normalizedQuery = normalizeRefSearchQuery(args.query) - if (!normalizedQuery) { - return [] - } try { // Why: argv (including the two-remote-glob rationale) lives in // buildSearchBaseRefsArgv so the SSH and local paths cannot drift. - const [result, remotesResult] = await Promise.all([ - provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path), - provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) - ]) + const remotesPromise = provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) + let result: { stdout: string } + try { + result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery, limit), repo.path) + } catch (err) { + if (!isForEachRefExcludeUnsupportedError(err)) { + throw err + } + result = await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }), + repo.path + ) + } + const remotesResult = await remotesPromise // Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline // to the shared helper so the SSH and local paths cannot diverge. // See parseAndFilterSearchRefs in ../git/repo.ts for the dedup + diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 3499ac466c2..4d02a8d1df6 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -156,6 +156,14 @@ describe('telemetry IPC handlers', () => { }) }) + it('drops main-owned events from renderer telemetry IPC', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:track')! + handler({}, 'app_starred_orca', { source: 'settings' }) + expect(trackMock).not.toHaveBeenCalled() + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) + it('injects cohort for setup script prompt events', () => { registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 }) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 29d57536d6d..c5fe79b833d 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -53,6 +53,8 @@ import type { OptInVia } from '../../shared/telemetry-events' // mirrors how other core-handlers accept the store explicitly. let storeRef: Store | null = null +const MAIN_OWNED_TELEMETRY_EVENTS = new Set(['app_starred_orca']) + /** * Derive the `via` discriminator for a `telemetry:setOptIn` call from * main-owned state. Called BEFORE any state mutation so the cohort + opt-in @@ -117,6 +119,13 @@ export function registerTelemetryHandlers(store: Store): void { if (props !== null && props !== undefined && typeof props !== 'object') { return } + const eventName = name as EventName + // Why: some event schemas are registered for main-owned emissions only. + // Letting renderer IPC emit them would let compromised content spoof + // product outcomes that must be tied to a successful main-side action. + if (MAIN_OWNED_TELEMETRY_EVENTS.has(eventName)) { + return + } // Inject cohort here, at the IPC entry, only for events whose schemas // declare `nth_repo_added` (see `COHORT_EXTENDED` in telemetry-events.ts). // The selectivity is load-bearing: schemas are `.strict()`, so adding @@ -130,7 +139,6 @@ export function registerTelemetryHandlers(store: Store): void { // The two injection sets are disjoint by construction today — no schema // declares both `nth_repo_added` and `cohort` — but combining them via // spread keeps that an additive change rather than a structural one. - const eventName = name as EventName const baseProps = (props ?? {}) as Record const withRepoCohort = isCohortExtendedEvent(eventName) ? { ...baseProps, ...getCohortAtEmit() } diff --git a/src/main/ipc/workspace-ports.test.ts b/src/main/ipc/workspace-ports.test.ts index c688cdb156f..ce66b79b8b6 100644 --- a/src/main/ipc/workspace-ports.test.ts +++ b/src/main/ipc/workspace-ports.test.ts @@ -258,6 +258,26 @@ describe('registerWorkspacePortHandlers', () => { port: 3002 }) }) + + it('unsubscribes the previous advertised URL listener when handlers are registered again', () => { + const store = makeStore() + const firstUnsubscribe = vi.fn() + const secondUnsubscribe = vi.fn() + const onDidChange = vi + .fn() + .mockReturnValueOnce(firstUnsubscribe) + .mockReturnValueOnce(secondUnsubscribe) + + registerWorkspacePortHandlers(store as never, { + advertisedUrlEvents: { onDidChange } + }) + registerWorkspacePortHandlers(store as never, { + advertisedUrlEvents: { onDidChange } + }) + + expect(firstUnsubscribe).toHaveBeenCalledTimes(1) + expect(secondUnsubscribe).not.toHaveBeenCalled() + }) }) function workspacePort({ pid, port }: { pid: number; port: number }): WorkspacePort { diff --git a/src/main/ipc/workspace-ports.ts b/src/main/ipc/workspace-ports.ts index b2041fa4e09..27d1df254d7 100644 --- a/src/main/ipc/workspace-ports.ts +++ b/src/main/ipc/workspace-ports.ts @@ -19,6 +19,8 @@ type WorkspacePortHandlersOptions = { getWindows?: () => BrowserWindow[] } +let unsubscribeAdvertisedUrlChanges: (() => void) | null = null + export function registerWorkspacePortHandlers( store: Store, options: WorkspacePortHandlersOptions = {} @@ -27,7 +29,8 @@ export function registerWorkspacePortHandlers( const advertisedUrlEvents = options.advertisedUrlEvents ?? advertisedUrlWatcher const getWindows = options.getWindows ?? (() => BrowserWindow.getAllWindows()) - advertisedUrlEvents.onDidChange((event) => { + unsubscribeAdvertisedUrlChanges?.() + unsubscribeAdvertisedUrlChanges = advertisedUrlEvents.onDidChange((event) => { const localWorktrees = getStoreWorkspacePortProbes(store) if (!localWorktrees.some((worktree) => worktree.id === event.worktreeId)) { return diff --git a/src/main/openclaude/hook-service.ts b/src/main/openclaude/hook-service.ts new file mode 100644 index 00000000000..5f93d47d813 --- /dev/null +++ b/src/main/openclaude/hook-service.ts @@ -0,0 +1,8 @@ +import { ClaudeHookService } from '../claude/hook-service' +import { OPENCLAUDE_HOOK_SETTINGS } from '../claude/hook-settings' + +export const openClaudeHookService = new ClaudeHookService({ + agent: 'openclaude', + displayName: 'OpenClaude', + settings: OPENCLAUDE_HOOK_SETTINGS +}) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index e06841f4043..9871805b496 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -164,6 +164,7 @@ describe('LocalPtyProvider', () => { provider.configure({ buildSpawnEnv: (_id, env) => { env.CODEX_HOME = 'C:\\Users\\jin\\.codex' + env.ORCA_CODEX_HOME = 'C:\\Users\\jin\\.codex' return env } }) @@ -177,6 +178,30 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('does not pass a WSL managed Codex home into Windows terminals', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() }) it('preserves an explicit Linux Codex home for WSL terminals', async () => { @@ -197,6 +222,82 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.codex-alt') + expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME') + }) + + it('translates a WSL managed Codex home before launching a WSL terminal', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.local/share/orca/codex-accounts/a/home') + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBe( + '/home/jin/.local/share/orca/codex-accounts/a/home' + ) + expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME') + expect(spawnCall[2].env.WSLENV).toContain('ORCA_CODEX_HOME') + }) + + it('does not pass a WSL managed Codex home into a different WSL distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + env.CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + env.ORCA_CODEX_HOME = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\repo' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[2].env.CODEX_HOME).toBeUndefined() + expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('uses the preferred WSL distro for Windows cwd WSL terminals', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[1]).toEqual([ + '-d', + 'Debian', + '--', + 'bash', + '-c', + "cd '/mnt/c/Users/jin/repo' && exec bash -l" + ]) }) it('does not inherit parent Orca pane identity when caller omits pane env', async () => { @@ -371,6 +472,25 @@ describe('LocalPtyProvider', () => { expect(onExit).toHaveBeenCalledWith(id, -1) }) + it('does not destroy after an intentional Windows shutdown kill', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const killSpy = vi.fn() + const destroySpy = vi.fn(() => { + killSpy() + }) + spawnMock.mockReturnValue({ + ...mockProc, + kill: killSpy, + destroy: destroySpy + }) + + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + await provider.shutdown(id, { immediate: true }) + + expect(killSpy).toHaveBeenCalledTimes(1) + expect(destroySpy).not.toHaveBeenCalled() + }) + it('is a no-op for unknown PTY ids', async () => { await provider.shutdown('nonexistent', { immediate: true }) expect(mockProc.kill).not.toHaveBeenCalled() @@ -510,5 +630,23 @@ describe('LocalPtyProvider', () => { const list = await provider.listProcesses() expect(list).toHaveLength(0) }) + + it('does not destroy after intentional Windows orphan kills', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const destroySpy = vi.fn() + const killSpy = vi.fn() + spawnMock.mockReturnValue({ + ...mockProc, + kill: killSpy, + destroy: destroySpy + }) + + await provider.spawn({ cols: 80, rows: 24 }) + + provider.killAll() + + expect(killSpy).toHaveBeenCalledTimes(1) + expect(destroySpy).not.toHaveBeenCalled() + }) }) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 95bd66b6f5a..c14c9b88884 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -31,7 +31,8 @@ import { STARTUP_COMMAND_READY_MAX_WAIT_MS } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' -import { isHostCodexHomeForWsl } from '../pty/codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' +import { addWslEnvKeys } from '../wsl-env' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const @@ -93,10 +94,17 @@ function disposePtyListeners(id: string): void { function getWslContextFromWorktreeId( worktreeId: string | undefined -): { distro: string } | undefined { +): { distro: string; treatPosixCwdAsWsl: true } | undefined { const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null - return wslInfo ? { distro: wslInfo.distro } : undefined + return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined +} + +function getWslContextFromPreferredDistro( + distro: string | null | undefined +): { distro: string } | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed } : undefined } function clearPtyState(id: string): void { @@ -106,7 +114,7 @@ function clearPtyState(id: string): void { ptyLoadGeneration.delete(id) } -function destroyPtyProcess(proc: pty.IPty): void { +function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void { // Why: node-pty's UnixTerminal.destroy() closes the master socket, which // releases the ptmx fd to the OS — without this call the fd leaks until GC // (see docs/fix-pty-fd-leak.md). destroy() also registers a close listener @@ -114,9 +122,11 @@ function destroyPtyProcess(proc: pty.IPty): void { // the time that listener runs the child may have exited and its pid been // recycled to an unrelated user process — SIGHUP would land on a Chrome tab, // editor, etc. Neutralize proc.kill on this instance before calling - // destroy() to defuse the hazard. Windows exempt: WindowsTerminal.destroy - // IS a kill() call via _deferNoArgs, so neutralizing it would leak the - // ConPTY agent. + // destroy() to defuse the hazard. On Windows, destroy() is itself kill(); + // skip it only after we have already killed the ConPTY. + if (process.platform === 'win32' && options.alreadyKilled) { + return + } if (process.platform !== 'win32') { ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} } @@ -134,7 +144,7 @@ function safeKillAndClean(id: string, proc: pty.IPty): void { } catch { /* Process may already be dead */ } - destroyPtyProcess(proc) + destroyPtyProcess(proc, { alreadyKilled: true }) clearPtyState(id) } @@ -146,7 +156,7 @@ export type LocalPtyProviderOptions = { buildSpawnEnv?: ( id: string, baseEnv: Record, - ctx?: { command?: string; isWsl?: boolean } + ctx?: { command?: string; isWsl?: boolean; wslDistro?: string | null } ) => Record /** Whether worktree-scoped shell history is enabled. When true (or absent) * and a worktreeId is provided, HISTFILE is scoped per-worktree. */ @@ -183,6 +193,10 @@ export class LocalPtyProvider implements IPtyProvider { const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null const worktreeWslContext = process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined + const preferredWslContext = + process.platform === 'win32' + ? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) + : undefined let shellPath: string let shellArgs: string[] @@ -236,7 +250,12 @@ export class LocalPtyProvider implements IPtyProvider { // same shellArgs for the same (shell, cwd) pair. The helper keeps CJK // UTF-8 setup (chcp 65001), PowerShell $PROFILE dot-sourcing, and the // wsl.exe /mnt/ cwd translation in one place. - const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, worktreeWslContext) + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + cwd, + defaultCwd, + worktreeWslContext ?? preferredWslContext + ) shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd @@ -290,17 +309,56 @@ export class LocalPtyProvider implements IPtyProvider { } const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + const launchWslDistro = + wslInfo?.distro ?? worktreeWslContext?.distro ?? preferredWslContext?.distro ?? null const finalEnv = this.opts.buildSpawnEnv - ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, isWsl: isWslShell }) + ? this.opts.buildSpawnEnv(id, spawnEnv, { + command: args.command, + isWsl: isWslShell, + wslDistro: launchWslDistro + }) : spawnEnv - if ( - process.platform === 'win32' && - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' && - isHostCodexHomeForWsl(finalEnv.CODEX_HOME) - ) { - // Why: Orca's selected Codex runtime home is host-local. WSL Codex must - // use its Linux-side ~/.codex instead of inheriting a Windows path. - delete finalEnv.CODEX_HOME + if (process.platform === 'win32') { + const codexHomeWslInfo = finalEnv.CODEX_HOME ? parseWslPath(finalEnv.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } else { + finalEnv.CODEX_HOME = codexHomeWslInfo.linuxPath + finalEnv.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + // Why: wsl.exe only imports non-default env vars named in WSLENV. + addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, { + distro: codexHomeWslInfo.distro + }) + shellArgs = resolved.shellArgs + effectiveCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + } + } + } else if (isHostCodexHomeForWsl(finalEnv.CODEX_HOME)) { + // Why: Orca's selected Codex runtime home is host-local. WSL Codex + // must use its Linux-side ~/.codex instead of a Windows path. + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } else if (finalEnv.CODEX_HOME) { + addWslEnvKeys(finalEnv, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (finalEnv.CLAUDE_CONFIG_DIR) { + // Why: managed WSL Claude accounts pass a Linux CLAUDE_CONFIG_DIR + // through Windows wsl.exe; non-default env vars need WSLENV import. + addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR']) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) { + // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use + // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore + // CODEX_HOME from it after user profiles run. + delete finalEnv.CODEX_HOME + delete finalEnv.ORCA_CODEX_HOME + } } if (!wslInfo && process.platform !== 'win32') { // Why: any Orca-injected overlay env that user rc files can clobber @@ -501,7 +559,7 @@ export class LocalPtyProvider implements IPtyProvider { } catch { /* Process may already be dead */ } - destroyPtyProcess(proc) + destroyPtyProcess(proc, { alreadyKilled: true }) ptyProcesses.delete(id) ptyShellName.delete(id) ptyLoadGeneration.delete(id) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 6e35f7d499f..0d0c334342b 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -241,12 +241,80 @@ describe('SshGitProvider', () => { args: ['exec', 'PROMPT'], cwd: '/home/user/repo', stdin: null, - timeoutMs: 60_000 + timeoutMs: 60_000, + operation: 'commit-message' }) expect(result).toEqual(execResult) }) - it('serializes non-interactive relay execs for the same cwd', async () => { + it('keeps SSH commit-message and pull-request execution lanes separate', async () => { + const completeRequests: (() => void)[] = [] + mux.request.mockImplementation((method) => { + if (method === 'agent.cancelExec') { + return Promise.resolve({ canceled: true }) + } + return new Promise((resolve) => { + completeRequests.push(() => + resolve({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false + }) + ) + }) + }) + const plan = { + binary: 'codex', + args: ['exec', 'PROMPT'], + stdinPayload: null, + label: 'Codex' + } + + const commit = provider.executeCommitMessagePlan(plan, '/home/user/repo', 60_000) + const pullRequest = provider.executeCommitMessagePlan( + plan, + '/home/user/repo', + 60_000, + 'pull-request-fields' + ) + + await waitForRequestCount(mux.request, 2) + expect(mux.request).toHaveBeenNthCalledWith(1, 'agent.execNonInteractive', { + binary: 'codex', + args: ['exec', 'PROMPT'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 60_000, + operation: 'commit-message' + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', { + binary: 'codex', + args: ['exec', 'PROMPT'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 60_000, + operation: 'pull-request-fields' + }) + + await provider.cancelGenerateCommitMessage('/home/user/repo') + await provider.cancelGenerateCommitMessage('/home/user/repo', 'pull-request-fields') + + expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.cancelExec', { + cwd: '/home/user/repo', + operation: 'commit-message' + }) + expect(mux.request).toHaveBeenNthCalledWith(4, 'agent.cancelExec', { + cwd: '/home/user/repo', + operation: 'pull-request-fields' + }) + + completeRequests.shift()?.() + completeRequests.shift()?.() + await Promise.all([commit, pullRequest]) + }) + + it('serializes non-interactive relay execs for the same cwd and operation', async () => { const completeRequests: (() => void)[] = [] mux.request.mockImplementation( () => @@ -263,16 +331,7 @@ describe('SshGitProvider', () => { ) const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000) - const second = provider.executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - }, - '/home/user/repo', - 60_000 - ) + const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) await waitForRequestCount(mux.request, 1) expect(mux.request).toHaveBeenCalledTimes(1) @@ -282,11 +341,11 @@ describe('SshGitProvider', () => { await waitForRequestCount(mux.request, 2) expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', { - binary: 'codex', - args: ['exec', 'PROMPT'], + binary: 'pnpm', + args: ['install'], cwd: '/home/user/repo', stdin: null, - timeoutMs: 60_000 + timeoutMs: 8000 }) completeRequests.shift()?.() await second @@ -309,16 +368,7 @@ describe('SshGitProvider', () => { ) const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000) - const second = provider.executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - }, - '/home/user/repo', - 60_000 - ) + const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) await waitForRequestCount(mux.request, 1) await provider.cancelNonInteractiveExec('/home/user/repo') @@ -360,16 +410,7 @@ describe('SshGitProvider', () => { 8000, controller.signal ) - const second = provider.executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - }, - '/home/user/repo', - 60_000 - ) + const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) await waitForRequestCount(mux.request, 1) controller.abort() @@ -381,11 +422,11 @@ describe('SshGitProvider', () => { await first await waitForRequestCount(mux.request, 3) expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.execNonInteractive', { - binary: 'codex', - args: ['exec', 'PROMPT'], + binary: 'pnpm', + args: ['install'], cwd: '/home/user/repo', stdin: null, - timeoutMs: 60_000 + timeoutMs: 8000 }) completeRequests.shift()?.() await second @@ -394,7 +435,10 @@ describe('SshGitProvider', () => { it('cancelGenerateCommitMessage sends best-effort relay cancellation', async () => { await provider.cancelGenerateCommitMessage('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) + expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { + cwd: '/home/user/repo', + operation: 'commit-message' + }) }) it('getDiff sends git.diff request', async () => { @@ -548,6 +592,24 @@ describe('SshGitProvider', () => { }) }) + it('fastForwardBranch sends git.fastForward request', async () => { + await provider.fastForwardBranch('/home/user/repo') + expect(mux.request).toHaveBeenCalledWith('git.fastForward', { + worktreePath: '/home/user/repo' + }) + }) + + it('fastForwardBranch forwards an explicit push target', async () => { + const pushTarget = { remoteName: 'fork', branchName: 'feature' } + + await provider.fastForwardBranch('/home/user/repo', pushTarget) + + expect(mux.request).toHaveBeenCalledWith('git.fastForward', { + worktreePath: '/home/user/repo', + pushTarget + }) + }) + it('rebaseFromBase sends git.rebaseFromBase request', async () => { await provider.rebaseFromBase('/home/user/repo', 'upstream/main') diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index afce18e4ba0..b96361e527e 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -105,15 +105,22 @@ export class SshGitProvider implements IGitProvider { async executeCommitMessagePlan( plan: CommitMessagePlan, cwd: string, - timeoutMs: number + timeoutMs: number, + operation = 'commit-message' ): Promise { - return this.runQueuedNonInteractiveExec(cwd, { - binary: plan.binary, - args: plan.args, + return this.runQueuedNonInteractiveExec( cwd, - stdin: plan.stdinPayload, - timeoutMs - }) + { + binary: plan.binary, + args: plan.args, + cwd, + stdin: plan.stdinPayload, + timeoutMs, + operation + }, + undefined, + operation + ) } async execNonInteractive( @@ -138,28 +145,38 @@ export class SshGitProvider implements IGitProvider { ) } - async cancelNonInteractiveExec(cwd: string): Promise { - const queue = this.nonInteractiveExecQueues.get(cwd) + async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { + const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) if (queuedEntry) { queuedEntry.canceled = true return } - await this.cancelActiveNonInteractiveExec(cwd) + await this.cancelActiveNonInteractiveExec(cwd, operation) } - private async cancelActiveNonInteractiveExec(cwd: string): Promise { + private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { try { - await this.mux.request('agent.cancelExec', { cwd }) + await this.mux.request('agent.cancelExec', { + cwd, + ...(operation ? { operation } : {}) + }) } catch { // Best-effort: callers are already unwinding after cancellation. } } - async cancelGenerateCommitMessage(worktreePath: string): Promise { + async cancelGenerateCommitMessage( + worktreePath: string, + operation = 'commit-message' + ): Promise { // Why: best-effort — the relay returns `{canceled: false}` when there is // nothing in flight. Callers should not block UI updates on this. - await this.cancelNonInteractiveExec(worktreePath) + await this.cancelNonInteractiveExec(worktreePath, operation) + } + + private nonInteractiveLaneKey(cwd: string, operation?: string): string { + return JSON.stringify([operation || 'default', cwd]) } private async runQueuedNonInteractiveExec( @@ -171,10 +188,13 @@ export class SshGitProvider implements IGitProvider { stdin: string | null timeoutMs: number env?: Record + operation?: string }, - signal?: AbortSignal + signal?: AbortSignal, + operation?: string ): Promise { - const queue = this.nonInteractiveExecQueues.get(cwd) ?? [] + const laneKey = this.nonInteractiveLaneKey(cwd, operation) + const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] const previous = queue.at(-1)?.done ?? Promise.resolve() let releaseEntry!: () => void const entry: NonInteractiveExecQueueEntry = { @@ -191,13 +211,13 @@ export class SshGitProvider implements IGitProvider { release: () => releaseEntry() } queue.push(entry) - this.nonInteractiveExecQueues.set(cwd, queue) + this.nonInteractiveExecQueues.set(laneKey, queue) const abortEntry = (): void => { if (!entry.started) { entry.canceled = true return } - void this.cancelActiveNonInteractiveExec(cwd) + void this.cancelActiveNonInteractiveExec(cwd, operation) } if (signal?.aborted) { entry.canceled = true @@ -205,8 +225,8 @@ export class SshGitProvider implements IGitProvider { signal?.addEventListener('abort', abortEntry, { once: true }) } - // Why: the SSH relay tracks one non-interactive child per cwd; serializing - // here keeps cache cleanup and commit-message generation from overwriting it. + // Why: the SSH relay tracks children per operation; serialize only matching + // lanes so commit-message and PR-field generation can coexist. await previous.catch(() => {}) try { if (entry.canceled) { @@ -226,13 +246,13 @@ export class SshGitProvider implements IGitProvider { } finally { signal?.removeEventListener('abort', abortEntry) entry.release() - const currentQueue = this.nonInteractiveExecQueues.get(cwd) + const currentQueue = this.nonInteractiveExecQueues.get(laneKey) const entryIndex = currentQueue?.indexOf(entry) ?? -1 if (entryIndex >= 0) { currentQueue?.splice(entryIndex, 1) } if (currentQueue?.length === 0) { - this.nonInteractiveExecQueues.delete(cwd) + this.nonInteractiveExecQueues.delete(laneKey) } } } @@ -331,6 +351,13 @@ export class SshGitProvider implements IGitProvider { await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) } + async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.mux.request('git.fastForward', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + }) + } + async rebaseFromBase(worktreePath: string, baseRef: string): Promise { await this.mux.request('git.rebaseFromBase', { worktreePath, baseRef }) } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 1288b8b07f3..29761995cb2 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -41,6 +41,9 @@ export type PtySpawnOptions = { * changing the user's persistent default shell setting. Only consulted on * Windows; ignored on macOS/Linux where shell selection is not exposed. */ shellOverride?: string + /** Preferred WSL distro for generic `wsl.exe` launches. Worktree/session + * distro still wins when the cwd already identifies a WSL distro. */ + terminalWindowsWslDistro?: string | null /** Why: PowerShell is the top-level shell family in product terms, but on * Windows we may need to choose between inbox Windows PowerShell 5.1 and * pwsh.exe at spawn time. Threading the persisted implementation choice @@ -185,6 +188,7 @@ export type IGitProvider = { options?: { forceWithLease?: boolean } ): Promise pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise + fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise rebaseFromBase(worktreePath: string, baseRef: string): Promise fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise getBranchDiff( diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 7f2e13d596f..b702efaff93 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -131,7 +131,7 @@ describe('resolveWindowsShellLaunchArgs', () => { 'wsl.exe', '/home/alice/repo/subdir', 'C:\\Users\\alice', - { distro: 'Ubuntu' } + { distro: 'Ubuntu', treatPosixCwdAsWsl: true } ) expect(result.shellArgs).toEqual([ diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 5ff2ed26588..c032aa15194 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -27,6 +27,7 @@ export type WindowsShellLaunchArgs = { export type WindowsShellWslContext = { distro: string + treatPosixCwdAsWsl?: boolean } function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { @@ -84,7 +85,7 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext && cwd.startsWith('/')) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, @@ -94,7 +95,7 @@ export function resolveWindowsShellLaunchArgs( const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c' return { - shellArgs: buildWslShellArgs(linuxCwd), + shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro), effectiveCwd: defaultCwd, validationCwd: cwd } diff --git a/src/main/pty/codex-home-wsl-env.test.ts b/src/main/pty/codex-home-wsl-env.test.ts index c98958cbdcc..1077ea9855e 100644 --- a/src/main/pty/codex-home-wsl-env.test.ts +++ b/src/main/pty/codex-home-wsl-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isHostCodexHomeForWsl } from './codex-home-wsl-env' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from './codex-home-wsl-env' describe('isHostCodexHomeForWsl', () => { it('matches Windows paths that WSL Codex cannot use as CODEX_HOME', () => { @@ -14,4 +14,10 @@ describe('isHostCodexHomeForWsl', () => { expect(isHostCodexHomeForWsl('')).toBe(false) expect(isHostCodexHomeForWsl(undefined)).toBe(false) }) + + it('matches Linux paths that host Codex cannot use on Windows', () => { + expect(isWslCodexHomeForHost('/home/jin/.local/share/orca/codex-accounts/a/home')).toBe(true) + expect(isWslCodexHomeForHost('C:\\Users\\jin\\.codex')).toBe(false) + expect(isWslCodexHomeForHost(undefined)).toBe(false) + }) }) diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index 13efe2ad8c8..b642e7661f6 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -5,3 +5,11 @@ export function isHostCodexHomeForWsl(value: string | undefined): boolean { } return /^[A-Za-z]:(?:[\\/]|$)/.test(trimmed) || trimmed.startsWith('\\\\') } + +export function isWslCodexHomeForHost(value: string | undefined): boolean { + const trimmed = value?.trim() + if (!trimmed) { + return false + } + return trimmed.startsWith('/') +} diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 6bca4c9146f..b68ce2cd671 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -99,6 +99,30 @@ describe('fetchClaudeRateLimits', () => { } }) + it('does not read host credentials when WSL config resolution fails', async () => { + await expect( + fetchClaudeRateLimits({ + authPreparation: { + configDir: '/Users/test/.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + } + }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'WSL Claude config unavailable for Ubuntu' + }) + + expect(readFileMock).not.toHaveBeenCalled() + expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('reads scoped default-config Keychain credentials for OAuth usage fetches', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { @@ -168,7 +192,10 @@ describe('fetchClaudeRateLimits', () => { status: 'ok' }) - expect(readFileMock).toHaveBeenCalledWith('/Users/test/.claude/.credentials.json', 'utf-8') + expect(readFileMock).toHaveBeenCalledWith( + join('/Users/test/.claude', '.credentials.json'), + 'utf-8' + ) expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 0d56e06fbe9..22d2aed30f4 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,11 +1,13 @@ /* eslint-disable max-lines -- Why: this module keeps Claude credential source ordering, OAuth usage fetch semantics, and PTY fallback behavior together so subscription usage state cannot drift across code paths. */ +import { existsSync, lstatSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' import { net, session } from 'electron' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { parseWslUncPath } from '../../shared/wsl-paths' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { @@ -307,6 +309,17 @@ async function fetchViaOAuth(token: string): Promise { export async function fetchClaudeRateLimits(options?: { authPreparation?: ClaudeRuntimeAuthPreparation }): Promise { + if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Claude config unavailable for ${options.authPreparation.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + // Path A: try OAuth API if we have a genuine OAuth token const oauthCredentials = await readOAuthCredentials(options?.authPreparation?.configDir) if (oauthCredentials.token) { @@ -368,6 +381,9 @@ export async function fetchClaudeRateLimits(options?: { export type InactiveClaudeAccountInfo = { id: string managedAuthPath: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null } // Why: reads an inactive account's OAuth token directly from its managed @@ -375,6 +391,14 @@ export type InactiveClaudeAccountInfo = { // Using ClaudeRuntimeAuthService would overwrite the active account's auth. async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promise { try { + if (account.managedAuthRuntime === 'wsl') { + const managedAuthPath = resolveOwnedWslClaudeManagedAuthPath(account) + if (!managedAuthPath) { + return null + } + const raw = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') + return raw ? parseOAuthCredentialsJson(raw).token : null + } const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { adoptLegacyMarker: true }) @@ -395,6 +419,36 @@ async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promis } } +function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo): string | null { + if (process.platform !== 'win32') { + return null + } + const wslInfo = parseWslUncPath(account.managedAuthPath) + if (!wslInfo || (account.wslDistro && wslInfo.distro !== account.wslDistro)) { + return null + } + const linuxPath = account.wslLinuxAuthPath ?? wslInfo.linuxPath + if ( + !linuxPath.includes('/.local/share/orca/claude-accounts/') || + !linuxPath.endsWith(`/${account.id}/auth`) + ) { + return null + } + try { + const markerPath = path.join(account.managedAuthPath, '.orca-managed-claude-auth') + if ( + !existsSync(markerPath) || + lstatSync(markerPath).isSymbolicLink() || + readFileSync(markerPath, 'utf-8').trim() !== account.id + ) { + return null + } + return account.managedAuthPath + } catch { + return null + } +} + export async function fetchManagedAccountUsage( account: InactiveClaudeAccountInfo ): Promise { diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 8f0dc78bc23..1e31e26afa8 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -129,6 +129,10 @@ const STARTUP_DELAY_MS = 2_000 const SETTLE_AFTER_STOP_MS = 2_000 const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + function describeClaudeUsageFailure(output: string): string { if (RATE_LIMITED_RE.test(output)) { return 'Claude usage is rate limited right now.' @@ -167,14 +171,34 @@ export async function fetchViaPty(options?: { // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 // and ensure the command path is properly quoted if it contains spaces. const isWin32 = process.platform === 'win32' - const spawnFile = isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = isWin32 ? ['/c', `"${claudeCommand}"`] : [] - const spawnEnv = applyClaudeEnvPatch( { ...process.env, TERM: 'xterm-256color' } as Record, options?.authPreparation?.envPatch ?? {}, { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } ) + const authPreparation = options?.authPreparation + const wslConfig = + authPreparation?.runtime === 'wsl' && + authPreparation.wslDistro && + authPreparation.wslLinuxConfigDir + ? { + distro: authPreparation.wslDistro, + linuxConfigDir: authPreparation.wslLinuxConfigDir + } + : null + const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand + const spawnArgs = wslConfig + ? [ + '-d', + wslConfig.distro, + '--', + 'bash', + '-lc', + `export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}; exec claude` + ] + : isWin32 + ? ['/c', `"${claudeCommand}"`] + : [] const term = pty.spawn(spawnFile, spawnArgs, { name: 'xterm-256color', diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts new file mode 100644 index 00000000000..8ae7722f656 --- /dev/null +++ b/src/main/rate-limits/claude-rate-limit-target.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' + +function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { + const next = { ...settings } as Partial + delete next.localAccountRuntime + return next as GlobalSettings +} + +describe('getInitialClaudeRateLimitTarget', () => { + it('uses the configured WSL account runtime before agent detection runtime', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Fedora', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) + }) + + it('uses the single selected WSL account distro when account runtime is WSL default', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account-1', + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the configured WSL agent runtime and distro', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsWslDistro: 'Debian' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the Windows WSL terminal setting when agent runtime is implicit', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses a single WSL-only active account after restart', () => { + expect( + getInitialClaudeRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }) + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('keeps explicit host runtime on host', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'host', + localAgentRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) +}) diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts new file mode 100644 index 00000000000..68b33cdd9a8 --- /dev/null +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -0,0 +1,71 @@ +import type { GlobalSettings } from '../../shared/types' +import { + getClaudeWslSelectionKey, + normalizeClaudeRuntimeSelection, + type ClaudeAccountSelectionTarget +} from '../claude-accounts/runtime-selection' + +function normalizeOptionalDistro(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { + const selection = normalizeClaudeRuntimeSelection(settings) + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + const [distroKey] = selectedWslEntries[0] + return distroKey === getClaudeWslSelectionKey(null) ? null : distroKey +} + +export function getInitialClaudeRateLimitTarget( + settings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): ClaudeAccountSelectionTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + if (settings.localAccountRuntime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAccountWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) ?? + getSingleSelectedWslDistro(settings) + } + } + + if ( + settings.localAgentRuntime === 'wsl' || + (settings.localAgentRuntime == null && + platform === 'win32' && + settings.terminalWindowsShell === 'wsl.exe') + ) { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAgentWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) + } + } + + const selection = normalizeClaudeRuntimeSelection(settings) + if (!selection.host) { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length === 1) { + const [distroKey] = selectedWslEntries[0] + return { + runtime: 'wsl', + wslDistro: distroKey === getClaudeWslSelectionKey(null) ? null : distroKey + } + } + } + + return { runtime: 'host' } +} diff --git a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts new file mode 100644 index 00000000000..08674c9d98a --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts @@ -0,0 +1,130 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + resolveCodexCommandMock: vi.fn(), + ptySpawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + spawn: childSpawnMock +})) + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + +vi.mock('node-pty', () => ({ + spawn: ptySpawnMock +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +function makeDisposable() { + return { dispose: vi.fn() } +} + +function makeRpcChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { write: ReturnType } + kill: ReturnType + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { write: vi.fn() } + child.kill = vi.fn() + return child +} + +describe('fetchCodexRateLimits auth errors', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resolveCodexCommandMock.mockReturnValue('codex') + }) + + it('returns Codex RPC auth refresh errors without masking them behind PTY fallback', async () => { + const rpcChild = makeRpcChild() + const authError = + 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.' + + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + error: { code: -32000, message: authError } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + session: null, + weekly: null, + status: 'error', + error: authError + }) + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + + it('preserves Codex PTY auth errors when the CLI exits before status is available', async () => { + const ptyHandlers: { onData?: (data: string) => void; onExit?: () => void } = {} + const authError = + 'Error loading configuration: Your authentication session could not be refreshed automatically.' + + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn((callback) => { + ptyHandlers.onExit = callback + return makeDisposable() + }), + write: vi.fn(), + kill: vi.fn() + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + ptyHandlers.onData?.(`${authError}\n`) + ptyHandlers.onExit?.() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + session: null, + weekly: null, + status: 'error', + error: authError + }) + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 9a12722303f..77da7f8d36f 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -110,6 +110,23 @@ describe('fetchCodexRateLimits', () => { }) }) + it('does not start the PTY fallback when disabled for background account previews', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + rpcChild.emit('close') + await vi.advanceTimersByTimeAsync(0) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + session: null, + weekly: null, + status: 'error' + }) + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) @@ -152,4 +169,138 @@ describe('fetchCodexRateLimits', () => { expect(result.session?.windowMinutes).toBe(300) expect(result.weekly?.windowMinutes).toBe(10080) }) + + it('runs rate-limit RPC through WSL when the Codex home is a WSL managed account', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { rateLimits: { primary: { usedPercent: 11 } } } + })}\n` + ) + ) + }, 0) + } + }) + + try { + const resultPromise = fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + await vi.advanceTimersByTimeAsync(1) + 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'" + ], + expect.objectContaining({ + env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + }) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => { + const originalPlatform = process.platform + const originalCodexHome = process.env.CODEX_HOME + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + process.env.CODEX_HOME = 'C:\\Users\\alice\\.codex' + + const rpcChild = makeRpcChild() + const ptyHandlers: { onData?: (data: string) => void } = {} + childSpawnMock.mockReturnValue(rpcChild) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn() + }) + + try { + const resultPromise = fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + 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 " + ], + expect.objectContaining({ + env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) + }) + ) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + onPtyData('>') + onPtyData('5h limit: 17%\nWeekly limit: 23%\n') + await vi.advanceTimersByTimeAsync(500) + + await expect(resultPromise).resolves.toMatchObject({ + session: { usedPercent: 17 }, + weekly: { usedPercent: 23 }, + status: 'ok' + }) + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index c7f81df4b40..d46da8c40a7 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -7,13 +7,17 @@ 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 { parseWslUncPath } from '../../shared/wsl-paths' +import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' const RPC_TIMEOUT_MS = 10_000 +const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { codexHomePath?: string | null + allowPtyFallback?: boolean } // --------------------------------------------------------------------------- @@ -43,6 +47,37 @@ type RpcRateLimitsResponse = { rateLimits?: RpcRateLimitsResult } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +function buildWslCodexCommand( + codexHomePath: string, + args: string[] +): { + command: string + args: string[] +} | null { + const wslInfo = parseWslUncPath(codexHomePath) + if (process.platform !== 'win32' || !wslInfo) { + return null + } + const script = [ + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, + `exec codex ${args.map(shellQuote).join(' ')}` + ].join('; ') + return { + command: 'wsl.exe', + args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] + } +} + +function cloneProcessEnvWithoutCodexHome(): NodeJS.ProcessEnv { + const env = { ...process.env } + delete env.CODEX_HOME + return env +} + function buildRpcMessage(id: number, method: string, params?: unknown): string { return `${JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} })}\n` } @@ -95,18 +130,21 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { const pty = await import('node-pty') - const codexCommand = resolveCodexCommand() + const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null + const codexCommand = wslCodex ? 'codex' : resolveCodexCommand() // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — // those need cmd.exe as an interpreter. resolveCodexCommand() may also fall @@ -328,8 +367,8 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise((resolve) => { let output = '' @@ -341,9 +380,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise void }[] = [] @@ -357,7 +396,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise + delete next.localAccountRuntime + return next as GlobalSettings +} + +describe('getInitialCodexRateLimitTarget', () => { + it('uses the configured WSL account runtime before agent detection runtime', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Fedora', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) + }) + + it('uses the single selected WSL account distro when account runtime is WSL default', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account-1', + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the configured WSL agent runtime and distro', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsWslDistro: 'Debian' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses the Windows WSL terminal setting when agent runtime is implicit', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }), + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('uses a single WSL-only active account after restart', () => { + expect( + getInitialCodexRateLimitTarget( + legacySettingsWithoutAccountRuntime({ + ...getDefaultSettings('/tmp'), + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }) + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('keeps explicit host runtime on host', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'host', + localAgentRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'wsl-account-1' } + } + }, + 'win32' + ) + ).toEqual({ runtime: 'host' }) + }) +}) diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts new file mode 100644 index 00000000000..0afb9d19e47 --- /dev/null +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -0,0 +1,73 @@ +import type { GlobalSettings } from '../../shared/types' +import { + getWslSelectionKey, + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from '../codex-accounts/runtime-selection' + +function normalizeOptionalDistro(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { + const selection = normalizeCodexRuntimeSelection(settings) + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + const [distroKey] = selectedWslEntries[0] + return distroKey === getWslSelectionKey(null) ? null : distroKey +} + +export function getInitialCodexRateLimitTarget( + settings: GlobalSettings, + platform: NodeJS.Platform = process.platform +): CodexAccountSelectionTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + if (settings.localAccountRuntime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAccountWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) ?? + getSingleSelectedWslDistro(settings) + } + } + + if ( + settings.localAgentRuntime === 'wsl' || + (settings.localAgentRuntime == null && + platform === 'win32' && + settings.terminalWindowsShell === 'wsl.exe') + ) { + return { + runtime: 'wsl', + wslDistro: + normalizeOptionalDistro(settings.localAgentWslDistro) ?? + normalizeOptionalDistro(settings.terminalWindowsWslDistro) + } + } + + const selection = normalizeCodexRuntimeSelection(settings) + if (!selection.host) { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length === 1) { + const [distroKey] = selectedWslEntries[0] + // Why: after restart there is no last-clicked switcher target, but a + // single WSL-only active account is the least surprising quota context. + return { + runtime: 'wsl', + wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey + } + } + } + + return { runtime: 'host' } +} diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts index cc1143279d1..094931a97b0 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -69,7 +69,7 @@ describe('cleanupHiddenRateLimitPty', () => { expect(killMock).toHaveBeenCalledWith() }) - it('does not neutralize kill on Windows because destroy closes ConPTY through kill', () => { + it('does not destroy after an intentional Windows kill because destroy kills again', () => { setPlatform('win32') const term = { kill: vi.fn(), @@ -80,7 +80,22 @@ describe('cleanupHiddenRateLimitPty', () => { cleanupHiddenRateLimitPty(term, [], { kill: true }) - expect(term.kill).toHaveBeenCalledTimes(2) + expect(term.kill).toHaveBeenCalledTimes(1) + expect(term.destroy).not.toHaveBeenCalled() + }) + + it('destroys a Windows PTY after natural exit so ConPTY cleanup still runs', () => { + setPlatform('win32') + const term = { + kill: vi.fn(), + destroy: vi.fn(() => { + term.kill() + }) + } + + cleanupHiddenRateLimitPty(term, [], { kill: false }) + + expect(term.kill).toHaveBeenCalledTimes(1) expect(term.destroy).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts index 215bccda7bc..6d85421203c 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -22,6 +22,12 @@ export function cleanupHiddenRateLimitPty( } catch { /* already exited */ } + + // Why: node-pty WindowsTerminal.destroy() calls kill() again, which can + // close the same ConPTY handle twice after an intentional termination. + if (process.platform === 'win32') { + return + } } // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 3c0310569e5..10d5a74c44b 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -230,6 +230,201 @@ describe('RateLimitService', () => { expect(state.opencodeGo?.session?.usedPercent).toBe(40) }) + it('passes the selected WSL Codex home into active account rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + service.setCodexHomePathResolver(resolver) + + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refreshForCodexAccountChange(null, { runtime: 'wsl', wslDistro: 'Ubuntu' }) + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ codexHomePath: wslCodexHome }) + ) + }) + + it('uses the initialized WSL target for active Codex rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + service.setCodexHomePathResolver(resolver) + service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ codexHomePath: wslCodexHome }) + ) + }) + + it('does not fetch host Codex usage when WSL home resolution fails', async () => { + const service = new RateLimitService() + const resolver = vi.fn(() => null) + service.setCodexHomePathResolver(resolver) + service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + + await service.refresh() + + expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(fetchCodexRateLimits).not.toHaveBeenCalled() + expect(service.getState().codex).toMatchObject({ + provider: 'codex', + status: 'error', + error: 'WSL Codex home unavailable for Ubuntu' + }) + }) + + it('uses the initialized WSL target for active Claude rate-limit fetches', async () => { + const service = new RateLimitService() + const resolver = vi.fn(async (target) => ({ + configDir: + target?.runtime === 'wsl' + ? '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude' + : 'C:\\Users\\jin\\.claude', + runtime: target?.runtime ?? 'host', + wslDistro: target?.wslDistro ?? null, + wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null, + envPatch: target?.runtime === 'wsl' ? { CLAUDE_CONFIG_DIR: '/home/jin/.claude' } : {}, + stripAuthEnv: target?.runtime === 'wsl', + provenance: target?.runtime === 'wsl' ? 'managed:wsl-account:wsl:Ubuntu' : 'system' + })) + service.setClaudeAuthPreparationResolver(resolver) + service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + 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 + }) + }) + expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + + it('does not cache host Codex usage under an outgoing WSL account', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' + service.setCodexHomePathResolver((target) => + target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + ) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + .mockResolvedValueOnce(okProvider('codex', 40, Date.now())) + + await service.refresh() + await service.refreshForCodexAccountChange('wsl-account-1', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(service.getState().inactiveCodexAccounts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })]) + ) + }) + + it('does not cache host Claude usage under an outgoing WSL account', async () => { + const service = new RateLimitService() + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) + service.setClaudeAuthPreparationResolver(async (target) => ({ + configDir: + target?.runtime === 'wsl' + ? '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude' + : 'C:\\Users\\jin\\.claude', + runtime: target?.runtime ?? 'host', + wslDistro: target?.wslDistro ?? null, + wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null, + envPatch: {}, + stripAuthEnv: target?.runtime === 'wsl', + provenance: target?.runtime === 'wsl' ? 'managed:wsl-account-1:wsl:Ubuntu' : 'system' + })) + + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(okProvider('claude', 20, Date.now())) + .mockResolvedValueOnce(okProvider('claude', 40, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + await service.refreshForClaudeAccountChange('wsl-account-1', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(service.getState().inactiveClaudeAccounts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })]) + ) + }) + + it('passes WSL Codex managed homes into inactive account rate-limit fetches', async () => { + const service = new RateLimitService() + const wslCodexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-1', managedHomePath: wslCodexHome } + ]) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 33, Date.now())) + + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledWith({ + codexHomePath: wslCodexHome, + allowPtyFallback: false + }) + expect(service.getState().inactiveCodexAccounts).toEqual([ + { + accountId: 'account-1', + claude: expect.objectContaining({ + session: expect.objectContaining({ usedPercent: 33 }) + }), + updatedAt: expect.any(Number), + isFetching: false + } + ]) + }) + + it('does not start overlapping inactive Codex preview fetches', async () => { + const service = new RateLimitService() + const accountFetch = deferred() + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + ]) + vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise) + + const firstFetch = service.fetchInactiveCodexAccountsOnOpen() + await Promise.resolve() + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + + accountFetch.resolve(okProvider('codex', 50, Date.now())) + await firstFetch + }) + it('preserves Gemini buckets through getState after fetch', async () => { const service = new RateLimitService() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 1ef3557e992..82ac7753f75 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -11,14 +11,29 @@ import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetche import type { InactiveClaudeAccountInfo } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { + normalizeClaudeAccountSelectionTarget, + type ClaudeAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget +} from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { + normalizeCodexAccountSelectionTarget, + type CodexAccountSelectionTarget, + type NormalizedCodexAccountSelectionTarget +} from '../codex-accounts/runtime-selection' export type InactiveCodexAccountInfo = { id: string managedHomePath: string } +type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => string | null +type ClaudeAuthPreparationResolver = ( + target?: ClaudeAccountSelectionTarget +) => Promise + // Why: Claude's subscription usage endpoint has a tight request budget. Quota // state is informational, so prefer keeping a recent snapshot over polling it // into 429s during long focused Orca sessions. @@ -57,8 +72,16 @@ export class RateLimitService { private claudeFetchGeneration = 0 private opencodeFetchGeneration = 0 private lastOpencodeConfigHash = '' - private codexHomePathResolver: (() => string | null) | null = null - private claudeAuthPreparationResolver: (() => Promise) | null = null + private codexHomePathResolver: CodexHomePathResolver | null = null + private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null + private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } private settingsResolver: | (() => { opencodeSessionCookie: string @@ -86,14 +109,22 @@ export class RateLimitService { } } - setCodexHomePathResolver(resolver: () => string | null): void { + setCodexHomePathResolver(resolver: CodexHomePathResolver): void { this.codexHomePathResolver = resolver } - setClaudeAuthPreparationResolver(resolver: () => Promise): void { + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { + this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) + } + + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { this.claudeAuthPreparationResolver = resolver } + setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { + this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) + } + setSettingsResolver( resolver: () => { opencodeSessionCookie: string @@ -152,6 +183,8 @@ export class RateLimitService { getState(): RateLimitState { return { ...this.state, + claudeTarget: this.claudeFetchTarget, + codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( this.inactiveClaudeCache, this.inactiveClaudeFetching @@ -172,10 +205,19 @@ export class RateLimitService { return this.getState() } - async refreshForCodexAccountChange(outgoingAccountId?: string | null): Promise { - if (outgoingAccountId && this.state.codex?.session) { + async refreshForCodexAccountChange( + outgoingAccountId?: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + if ( + outgoingAccountId && + this.state.codex?.session && + this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + ) { this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) } + this.codexFetchTarget = nextTarget this.codexFetchGeneration += 1 this.lastInactiveCodexFetchAt = 0 // Why: switching the selected Codex account must immediately clear the old @@ -189,12 +231,34 @@ export class RateLimitService { return this.getState() } - async refreshForClaudeAccountChange(outgoingAccountId?: string | null): Promise { + async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async refreshForClaudeAccountChange( + outgoingAccountId?: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) // Why: snapshot the outgoing account's usage before clearing it so the // inline usage bars in the switcher can show last-known data immediately. - if (outgoingAccountId && this.state.claude?.session) { + if ( + outgoingAccountId && + this.state.claude?.session && + this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + ) { this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) } + this.claudeFetchTarget = nextTarget this.inactiveClaudeAccountsGeneration += 1 this.pruneInactiveClaudeState() this.claudeFetchGeneration += 1 @@ -207,6 +271,19 @@ export class RateLimitService { return this.getState() } + async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + this.claudeFetchTarget = nextTarget + this.claudeFetchGeneration += 1 + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + async fetchInactiveClaudeAccountsOnOpen(): Promise { if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return @@ -273,6 +350,9 @@ export class RateLimitService { if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return } + if (this.inactiveCodexFetching.size > 0) { + return + } const accounts = this.inactiveCodexAccountsResolver?.() ?? [] if (accounts.length === 0) { return @@ -288,7 +368,13 @@ export class RateLimitService { // 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. - const fresh = await fetchCodexRateLimits({ codexHomePath: account.managedHomePath }) + // 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 + }) const cached = this.inactiveCodexCache.get(account.id) ?? null this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) } catch { @@ -526,6 +612,50 @@ export class RateLimitService { } } + private isSameCodexTarget( + left: NormalizedCodexAccountSelectionTarget, + right: NormalizedCodexAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + private isSameClaudeTarget( + left: NormalizedClaudeAccountSelectionTarget, + right: NormalizedClaudeAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + private getCodexProvenance( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null + ): string { + const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' + return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` + } + + private getMissingWslCodexHomeResult( + target: NormalizedCodexAccountSelectionTarget + ): ProviderRateLimits | null { + if (target.runtime !== 'wsl') { + return null + } + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + + private shouldAllowCodexPtyFallback(): boolean { + // Why: quota UI refreshes run in the background. On Windows, hidden PTY + // fallback can crash inside ConPTY, so prefer RPC-only degradation there. + return process.platform !== 'win32' + } + private withFetchingStatus( current: ProviderRateLimits | null, provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' @@ -544,11 +674,13 @@ export class RateLimitService { } private async runFetchAllCycle(): Promise { - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const claudeTarget = this.claudeFetchTarget + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration - const codexHomePath = this.codexHomePathResolver?.() ?? null - const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system' + const codexTarget = this.codexFetchTarget + const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration const previousState = this.state const settings = this.settingsResolver?.() @@ -579,9 +711,16 @@ export class RateLimitService { : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go') }) + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) const [claudeResult, codexResult, geminiResult, opencodeGoResult] = await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }), - fetchCodexRateLimits({ codexHomePath }), + missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback() + }), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined) ]) @@ -641,14 +780,16 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - const latestCodexHomePath = this.codexHomePathResolver?.() ?? null - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const latestCodexProvenance = latestCodexHomePath ? `managed:${latestCodexHomePath}` : 'system' + const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration // Why: account switches can race in-flight Codex fetches. Only apply a @@ -675,8 +816,9 @@ export class RateLimitService { } private async runFetchCodexOnlyCycle(): Promise { - const codexHomePath = this.codexHomePathResolver?.() ?? null - const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system' + const codexTarget = this.codexFetchTarget + const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration const previousState = this.state @@ -685,7 +827,17 @@ export class RateLimitService { codex: this.withFetchingStatus(previousState.codex, 'codex') }) - const codex = await fetchCodexRateLimits({ codexHomePath }).catch( + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + const codex = await ( + missingWslCodexHome + ? Promise.resolve(missingWslCodexHome) + : fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback() + }) + ).catch( (err): ProviderRateLimits => ({ provider: 'codex', session: null, @@ -696,8 +848,8 @@ export class RateLimitService { }) ) - const latestCodexHomePath = this.codexHomePathResolver?.() ?? null - const latestCodexProvenance = latestCodexHomePath ? `managed:${latestCodexHomePath}` : 'system' + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance @@ -710,7 +862,8 @@ export class RateLimitService { } private async runFetchClaudeOnlyCycle(): Promise { - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const claudeTarget = this.claudeFetchTarget + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration const previousState = this.state @@ -731,10 +884,12 @@ export class RateLimitService { }) ) - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) this.updateState({ ...this.state, diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 1c38564dc1e..a6207bc0234 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -1,4 +1,6 @@ -/* eslint-disable max-lines */ +/* eslint-disable max-lines -- Why: runtime file command tests share mocked fs, + authorization, and watcher lifecycle fixtures; splitting would duplicate the + setup that makes cross-command filesystem behavior comparable. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as Fs from 'fs' import type * as FsPromises from 'fs/promises' diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index f9a26202593..a4288e56079 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -40,7 +40,7 @@ import { } from '../git/status' import { getHistory as getGitHistory } from '../git/history' import { getUpstreamStatus } from '../git/upstream' -import { gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' +import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE @@ -303,6 +303,23 @@ export class RuntimeGitCommands { return { ok: true } } + async fastForwardRuntimeGit( + worktreeSelector: string, + pushTarget?: GitPushTarget + ): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.fastForwardBranch(target.worktree.path, pushTarget) + return { ok: true } + } + await gitFastForward(target.worktree.path, pushTarget) + return { ok: true } + } + async rebaseRuntimeGitFromBase(worktreeSelector: string, baseRef: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -465,7 +482,8 @@ export class RuntimeGitCommands { return generateCommitMessageFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: target.worktree.path, - execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } @@ -498,7 +516,7 @@ export class RuntimeGitCommands { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { - await provider?.cancelGenerateCommitMessage(target.worktree.path) + await provider?.cancelGenerateCommitMessage(target.worktree.path, 'commit-message') return { ok: true } } cancelGenerateCommitMessageLocal(target.worktree.path) @@ -567,7 +585,8 @@ export class RuntimeGitCommands { return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: target.worktree.path, - execute: (plan, cwd, timeoutMs) => provider!.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider!.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } @@ -590,7 +609,7 @@ export class RuntimeGitCommands { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { - await provider?.cancelGenerateCommitMessage(target.worktree.path) + await provider?.cancelGenerateCommitMessage(target.worktree.path, 'pull-request-fields') return { ok: true } } cancelGeneratePullRequestFieldsLocal(target.worktree.path) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 16e4031af99..6867cca9e93 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -413,14 +413,18 @@ afterEach(() => { getIssueMock.mockResolvedValue(null) }) -function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void { +function syncSinglePty( + runtime: OrcaRuntimeService, + ptyId: string | null = 'pty-1', + options: { tabTitle?: string | null; paneTitle?: string | null } = {} +): void { runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [ { tabId: 'tab-1', worktreeId: TEST_WORKTREE_ID, - title: 'Codex', + title: options.tabTitle ?? 'Codex', activeLeafId: 'pane:1', layout: null } @@ -432,7 +436,7 @@ function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty- leafId: 'pane:1', paneRuntimeId: 1, ptyId, - paneTitle: null + paneTitle: options.paneTitle ?? null } ] }) @@ -833,6 +837,60 @@ describe('OrcaRuntimeService', () => { expect(shown.ptyId).toBe('pty-1') }) + it('routes PTY output through the PTY leaf index in large terminal graphs', () => { + const runtime = new OrcaRuntimeService(store) + const liveLeafCount = 2773 + const targetIndex = liveLeafCount - 17 + const tabs = Array.from({ length: liveLeafCount }, (_, index) => ({ + tabId: `tab-${index}`, + worktreeId: `repo-1::/tmp/worktree-${index}`, + title: `Terminal ${index}`, + activeLeafId: 'pane:1', + layout: null + })) + const leaves = Array.from({ length: liveLeafCount }, (_, index) => ({ + tabId: `tab-${index}`, + worktreeId: `repo-1::/tmp/worktree-${index}`, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: `pty-${index}` + })) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs, leaves }) + + const runtimePrivate = runtime as unknown as { + leaves: Map + leavesByPtyId: Map + } + const originalLeaves = runtimePrivate.leaves + runtimePrivate.leaves = new Proxy(originalLeaves, { + get(target, prop) { + if ( + prop === 'values' || + prop === 'entries' || + prop === 'keys' || + prop === Symbol.iterator + ) { + return () => { + throw new Error('onPtyData should use the PTY leaf index') + } + } + const value = Reflect.get(target, prop, target) + return typeof value === 'function' ? value.bind(target) : value + } + }) as Map + + runtime.onPtyData(`pty-${targetIndex}`, 'hello indexed\n', 123) + + const [targetLeaf] = runtimePrivate.leavesByPtyId.get(`pty-${targetIndex}`) ?? [] + expect(targetLeaf).toMatchObject({ + preview: 'hello indexed', + lastOutputAt: 123 + }) + expect(runtime.getStatus().liveLeafCount).toBe(liveLeafCount) + }) + it('resolves branch selectors when worktrees store refs/heads-prefixed branches', async () => { vi.mocked(listWorktrees).mockResolvedValueOnce([ { @@ -3973,6 +4031,77 @@ describe('OrcaRuntimeService', () => { } }) + it('injects pending orchestration messages into Cursor Agent without auto-submitting', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;\u280b Cursor Agent\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Cursor ready\x07', 101) + db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello cursor' }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello cursor')) + await vi.advanceTimersByTimeAsync(500) + const submitWrites = write.mock.calls.filter( + ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' + ) + expect(submitWrites).toHaveLength(0) + + // Why: Cursor Agent treats injected PTY text as editable prompt input. + // The user must submit it manually, but the banner should not replay on + // the next idle transition. + const unread = db.getUnreadMessages(terminal.handle) + expect(unread).toHaveLength(1) + expect(unread[0].read).toBe(0) + expect(unread[0].delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('still auto-submits to a non-Cursor agent when its idle title mentions Cursor Agent', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1', { tabTitle: 'cursor-repro-branch' }) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;. Investigate Cursor Agent\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;* Investigate Cursor Agent\x07', 101) + db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello claude' }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + await vi.advanceTimersByTimeAsync(500) + + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello claude')) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + db.close() + } finally { + vi.useRealTimers() + } + }) + it('does not replay an already-delivered message on a later idle transition', async () => { vi.useFakeTimers() try { @@ -5375,6 +5504,90 @@ describe('OrcaRuntimeService', () => { ]) }) + it('builds mobile session agent launch commands on the runtime host', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: { 'command-code': 'command-code --profile mobile' } + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'command-code' + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'command-code --profile mobile', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + }) + ) + }) + + it('rejects disabled mobile session agent launches before spawning', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: ['codex'], + agentCmdOverrides: {} + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'codex' + }) + ).rejects.toThrow('Selected agent is disabled') + expect(spawn).not.toHaveBeenCalled() + }) + + it('validates mobile terminal insertion anchors before resolving agent launch commands', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: ['codex'], + agentCmdOverrides: {} + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + afterTabId: 'stale-tab', + agent: 'codex' + }) + ).rejects.toThrow('after_tab_not_found') + expect(spawn).not.toHaveBeenCalled() + }) + it('forwards inactive mobile terminal creation to the renderer without focusing it', async () => { const focusTerminal = vi.fn() const runtime = new OrcaRuntimeService(store) @@ -6134,6 +6347,118 @@ describe('OrcaRuntimeService', () => { await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit') }) + it('returns capped SSH refs for empty runtime repo searches', async () => { + const remoteRepo = { + id: 'remote-repo', + path: '/home/user/repo', + displayName: 'remote', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: () => remoteRepo + } + const provider = { + exec: vi.fn().mockImplementation((argv: string[]) => { + if (argv[0] === 'remote') { + return Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) + } + return Promise.resolve({ + stdout: [ + 'refs/remotes/origin/main\0origin/main', + 'refs/remotes/upstream/feature-x\0upstream/feature-x', + 'refs/remotes/upstream/HEAD\0upstream/HEAD', + 'refs/heads/local-only\0local-only' + ].join('\n'), + stderr: '' + }) + }) + } + registerSshGitProvider('ssh-1', provider as never) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const result = await runtime.searchRepoRefs('id:remote-repo', '', 2) + + expect(result).toEqual({ + refs: ['origin/main', 'upstream/feature-x'], + refDetails: [ + { refName: 'origin/main', localBranchName: 'main' }, + { refName: 'upstream/feature-x', localBranchName: 'feature-x' } + ], + truncated: true + }) + expect(provider.exec).toHaveBeenCalledWith( + expect.arrayContaining([ + '--exclude=refs/remotes/**/HEAD', + '--count=12', + 'refs/heads/**/**', + 'refs/heads/**/**/**', + 'refs/remotes/**/**', + 'refs/remotes/**/**/**' + ]), + '/home/user/repo' + ) + expect(provider.exec).toHaveBeenCalledWith(['remote'], '/home/user/repo') + }) + + it('retries runtime SSH ref searches without --exclude for older git hosts', async () => { + const remoteRepo = { + id: 'remote-repo', + path: '/home/user/repo', + displayName: 'remote', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: () => remoteRepo + } + const provider = { + exec: vi.fn().mockImplementation((argv: string[]) => { + if (argv[0] === 'remote') { + return Promise.resolve({ stdout: 'origin\n', stderr: '' }) + } + if (argv.includes('--exclude=refs/remotes/**/HEAD')) { + return Promise.reject( + Object.assign(new Error("unknown option `exclude'"), { + stderr: "error: unknown option `exclude'" + }) + ) + } + return Promise.resolve({ + stdout: [ + 'refs/remotes/origin/main\0origin/main', + 'refs/remotes/origin/HEAD\0origin/HEAD', + 'refs/remotes/origin/feature-x\0origin/feature-x' + ].join('\n'), + stderr: '' + }) + }) + } + registerSshGitProvider('ssh-1', provider as never) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const result = await runtime.searchRepoRefs('id:remote-repo', '', 1) + + expect(result).toEqual({ + refs: ['origin/main'], + refDetails: [{ refName: 'origin/main', localBranchName: 'main' }], + truncated: true + }) + const forEachRefCalls = provider.exec.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'for-each-ref' + ) + expect(forEachRefCalls).toHaveLength(2) + expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD') + expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD') + expect(forEachRefCalls[1][0]).toContain('--count=108') + }) + it('resolves SSH worktrees when manually updating lineage', async () => { const remoteRepo = { id: 'remote-repo', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f4806c941a1..6b38055a34e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -170,6 +170,7 @@ import { updatePRTitle, updatePRDetails, mergePR, + setPRAutoMerge, updatePRState, requestPRReviewers, removePRReviewers, @@ -306,6 +307,7 @@ import { parseRemoteCount, resolveDefaultBaseRefViaExec, buildSearchBaseRefsArgv, + isForEachRefExcludeUnsupportedError, getRemoteDrift, getRecentDriftSubjects } from '../git/repo' @@ -553,6 +555,31 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { lastOscTitle: string | null } +function isCursorAgentOrchestrationTarget( + leaf: RuntimeLeafRecord, + tabTitle: string | null | undefined +): boolean { + return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle) +} + +function isCursorAgentTitle(title: string | null | undefined): boolean { + if (typeof title !== 'string') { + return false + } + const trimmed = title.trim() + const lower = trimmed.toLowerCase() + if ( + lower === 'cursor agent' || + lower === 'cursor ready' || + lower === 'cursor - action required' + ) { + return true + } + // Why: display labels can mention Cursor in another agent's task text. Only + // treat the controlled synthetic Cursor spinner title as Cursor identity. + return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed) +} + type RuntimePtyWorktreeRecord = { ptyId: string worktreeId: string @@ -1027,6 +1054,9 @@ export class OrcaRuntimeService { private mobileSessionTabsByWorktree = new Map() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() private leaves = new Map() + // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by + // ptyId keeps active TUI redraws independent of the total open terminal count. + private leavesByPtyId = new Map() private handles = new Map() private handleByLeafKey = new Map() private handleByPtyId = new Map() @@ -1625,6 +1655,7 @@ export class OrcaRuntimeService { runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, capabilities: [...RUNTIME_CAPABILITIES], + hostPlatform: process.platform, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } @@ -1744,6 +1775,7 @@ export class OrcaRuntimeService { } this.leaves = nextLeaves + this.rebuildLeafPtyIndex() this.notifyMobileSessionTabSnapshots() this.graphStatus = 'ready' this.refreshWritableFlags() @@ -2110,6 +2142,8 @@ export class OrcaRuntimeService { pullRuntimeGit: RuntimeGitCommands['pullRuntimeGit'] = this.gitCommands.pullRuntimeGit.bind( this.gitCommands ) + fastForwardRuntimeGit: RuntimeGitCommands['fastForwardRuntimeGit'] = + this.gitCommands.fastForwardRuntimeGit.bind(this.gitCommands) rebaseRuntimeGitFromBase: RuntimeGitCommands['rebaseRuntimeGitFromBase'] = this.gitCommands.rebaseRuntimeGitFromBase.bind(this.gitCommands) pushRuntimeGit: RuntimeGitCommands['pushRuntimeGit'] = this.gitCommands.pushRuntimeGit.bind( @@ -2185,10 +2219,8 @@ export class OrcaRuntimeService { registerPreAllocatedHandleForPty(ptyId: string, handle: string): void { this.handleByPtyId.set(ptyId, handle) - for (const leaf of this.leaves.values()) { - if (leaf.ptyId === ptyId) { - this.adoptPreAllocatedHandle(leaf) - } + for (const leaf of this.getLeavesForPty(ptyId)) { + this.adoptPreAllocatedHandle(leaf) } } @@ -2197,12 +2229,10 @@ export class OrcaRuntimeService { if (pty) { pty.connected = true } - for (const leaf of this.leaves.values()) { - if (leaf.ptyId === ptyId) { - leaf.connected = true - leaf.writable = this.graphStatus === 'ready' - this.adoptPreAllocatedHandle(leaf) - } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.connected = true + leaf.writable = this.graphStatus === 'ready' + this.adoptPreAllocatedHandle(leaf) } } @@ -2280,10 +2310,7 @@ export class OrcaRuntimeService { } } - for (const leaf of this.leaves.values()) { - if (leaf.ptyId !== ptyId) { - continue - } + for (const leaf of this.getLeavesForPty(ptyId)) { this.recordPtyWorktree(ptyId, leaf.worktreeId, { connected: true, lastOutputAt: pty?.lastOutputAt ?? at, @@ -2578,15 +2605,13 @@ export class OrcaRuntimeService { return } const status = detectAgentStatusFromTitle(title) - for (const leaf of this.leaves.values()) { - if (leaf.ptyId === ptyId) { - // Why: seed lastOscTitle even when the seeded title doesn't classify - // as an agent state, so worktree.ps recomputes status from the live - // title rather than treating the leaf as agentless. - leaf.lastOscTitle = title - if (status !== null) { - leaf.lastAgentStatus = status - } + for (const leaf of this.getLeavesForPty(ptyId)) { + // Why: seed lastOscTitle even when the seeded title doesn't classify + // as an agent state, so worktree.ps recomputes status from the live + // title rather than treating the leaf as agentless. + leaf.lastOscTitle = title + if (status !== null) { + leaf.lastAgentStatus = status } } } @@ -3441,10 +3466,7 @@ export class OrcaRuntimeService { this.pruneDisconnectedPtyTranscript(pty) } - for (const leaf of this.leaves.values()) { - if (leaf.ptyId !== ptyId) { - continue - } + for (const leaf of this.getLeavesForPty(ptyId)) { this.detachedPreAllocatedLeaves.delete(ptyId) leaf.connected = false leaf.writable = false @@ -5796,14 +5818,21 @@ export class OrcaRuntimeService { return [] } const normalizedQuery = normalizeRefSearchQuery(query) - if (!normalizedQuery) { - return [] - } try { - const [result, remotesResult] = await Promise.all([ - provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path), - provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) - ]) + const remotesPromise = provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) + let result: { stdout: string } + try { + result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery, limit), repo.path) + } catch (err) { + if (!isForEachRefExcludeUnsupportedError(err)) { + throw err + } + result = await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }), + repo.path + ) + } + const remotesResult = await remotesPromise const remotes = remotesResult.stdout .split('\n') .map((line) => line.trim()) @@ -6416,6 +6445,16 @@ export class OrcaRuntimeService { return mergePR(repo.path, prNumber, method, repo.connectionId ?? null, prRepo ?? null) } + async setRepoPRAutoMerge( + repoSelector: string, + prNumber: number, + enabled: boolean, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRAutoMerge(repo.path, prNumber, enabled, repo.connectionId ?? null, prRepo ?? null) + } + async updateRepoPRState( repoSelector: string, prNumber: number, @@ -9280,10 +9319,17 @@ export class OrcaRuntimeService { async createMobileSessionTerminal( worktreeSelector: string, - opts: { afterTabId?: string; targetGroupId?: string; command?: string; activate?: boolean } = {} + opts: { + afterTabId?: string + targetGroupId?: string + command?: string + agent?: TuiAgent + activate?: boolean + } = {} ): Promise { this.assertGraphReady() - const worktreeId = (await this.resolveWorktreeSelector(worktreeSelector)).id + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktreeId = worktree.id let afterDesktopTabId: string | undefined if (opts.afterTabId) { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) @@ -9293,6 +9339,7 @@ export class OrcaRuntimeService { } afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id } + const command = await this.resolveMobileSessionTerminalCommand(worktree, opts) const win = this.getAvailableAuthoritativeWindow() if (!win) { @@ -9300,7 +9347,7 @@ export class OrcaRuntimeService { worktreeId, opts.activate !== false, opts.afterTabId, - opts.command + command ) } const requestId = randomUUID() @@ -9331,7 +9378,7 @@ export class OrcaRuntimeService { worktreeId, afterTabId: afterDesktopTabId, targetGroupId: opts.targetGroupId, - command: opts.command, + command, activate: opts.activate }) }) @@ -9342,6 +9389,42 @@ export class OrcaRuntimeService { return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId) } + private async resolveMobileSessionTerminalCommand( + worktree: Worktree, + opts: { command?: string; agent?: TuiAgent } + ): Promise { + if (opts.command || !opts.agent) { + return opts.command + } + if (!this.store) { + throw new Error('runtime_unavailable') + } + const settings = this.store.getSettings() + if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + const repo = this.store.getRepo(worktree.repoId) + // Why: mobile may be running on iOS while the actual terminal shell is + // Windows/macOS/Linux or an SSH Linux host; quote for the host shell. + const platform: NodeJS.Platform = repo?.connectionId ? 'linux' : process.platform + const startupPlan = buildAgentStartupPlan({ + agent: opts.agent, + prompt: '', + cmdOverrides: settings.agentCmdOverrides ?? {}, + platform, + allowEmptyPromptLaunch: true + }) + if (!startupPlan) { + throw new Error(`Could not build launch command for ${opts.agent}.`) + } + if (repo?.connectionId) { + await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) + } else { + this.markLocalWorkspaceTrustedForAgent(opts.agent, worktree.path) + } + return startupPlan.launchCommand + } + private async createHeadlessMobileSessionTerminal( worktreeId: string, activate: boolean, @@ -9854,6 +9937,7 @@ export class OrcaRuntimeService { this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() this.leaves.clear() + this.leavesByPtyId.clear() this.handles.clear() this.handleByLeafKey.clear() // Why: same as markRendererReloading — pre-allocated CLI handles must @@ -10623,12 +10707,27 @@ export class OrcaRuntimeService { } private leafExistsForPty(ptyId: string): boolean { + return (this.leavesByPtyId.get(ptyId)?.length ?? 0) > 0 + } + + private rebuildLeafPtyIndex(): void { + const next = new Map() for (const leaf of this.leaves.values()) { - if (leaf.ptyId === ptyId) { - return true + if (!leaf.ptyId) { + continue + } + const leaves = next.get(leaf.ptyId) + if (leaves) { + leaves.push(leaf) + } else { + next.set(leaf.ptyId, [leaf]) } } - return false + this.leavesByPtyId = next + } + + private getLeavesForPty(ptyId: string): RuntimeLeafRecord[] { + return this.leavesByPtyId.get(ptyId) ?? [] } private getSummaryForRuntimeWorktreeId( @@ -11689,6 +11788,15 @@ export class OrcaRuntimeService { return } + const tabTitle = this.tabs.get(leaf.tabId)?.title + if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) { + // Why: Cursor Agent treats injected PTY text as editable prompt input. + // Push-on-idle may surface the message, but submitting it must stay + // under user control. + this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) + return + } + // Why: Claude Code treats large single PTY writes as paste events and // swallows a \r included in the same write. Send Enter separately after // a delay so the agent processes the pasted message first. Stamp diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 61794da9eb1..73bbbb7ad06 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -103,6 +103,25 @@ describe('resolveGroupAddress', () => { expect(result).toEqual(['term_b']) }) + it('matches @openclaude by terminal title', () => { + const terminals = [ + makeSummary('term_a', { title: 'OpenClaude' }), + makeSummary('term_b', { title: 'OpenClaude running' }), + makeSummary('term_c', { title: 'Claude Code' }) + ] + const result = resolveGroupAddress('@openclaude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('does not match OpenClaude titles through @claude', () => { + const terminals = [ + makeSummary('term_a', { title: 'Claude Code' }), + makeSummary('term_b', { title: 'OpenClaude running' }) + ] + const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + it('matches @codex by terminal title', () => { const terminals = [ makeSummary('term_a', { title: 'Codex CLI' }), diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 8c94759250b..2989c61416f 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -4,7 +4,7 @@ import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' // Resolution is done at send-time: one message record per recipient, same thread_id, // so each recipient gets their own read-tracking (Section 4.5). -const AGENT_NAME_GROUPS = ['claude', 'codex', 'opencode', 'gemini'] as const +const AGENT_NAME_GROUPS = ['claude', 'openclaude', 'codex', 'opencode', 'gemini'] as const export type GroupAddress = | '@all' @@ -16,6 +16,15 @@ export function isGroupAddress(to: string): boolean { return to.startsWith('@') } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { + const tokenRe = new RegExp(`(? t.handle) } diff --git a/src/main/runtime/rpc/methods/clipboard.test.ts b/src/main/runtime/rpc/methods/clipboard.test.ts new file mode 100644 index 00000000000..d8b729d317c --- /dev/null +++ b/src/main/runtime/rpc/methods/clipboard.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' + +const { saveClipboardImageBufferAsTempFile } = vi.hoisted(() => ({ + saveClipboardImageBufferAsTempFile: vi.fn() +})) + +vi.mock('../../../window/clipboard-image-temp-file', () => ({ + saveClipboardImageBufferAsTempFile +})) + +import { + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, + CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT, + CLIPBOARD_METHODS, + resetClipboardImageUploadsForTest +} from './clipboard' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +function makeDispatcher(): RpcDispatcher { + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS }) +} + +describe('clipboard RPC methods', () => { + beforeEach(() => { + saveClipboardImageBufferAsTempFile.mockReset() + resetClipboardImageUploadsForTest() + }) + + afterEach(() => { + vi.useRealTimers() + resetClipboardImageUploadsForTest() + }) + + it('saves browser-provided clipboard image bytes on the runtime host', async () => { + saveClipboardImageBufferAsTempFile.mockResolvedValue( + 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-image.png' + ) + const dispatcher = makeDispatcher() + + const response = await dispatcher.dispatch( + makeRequest('clipboard.saveImageAsTempFile', { + contentBase64: Buffer.from('png-bytes').toString('base64'), + connectionId: null + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-image.png' + }) + expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), { + connectionId: null + }) + }) + + it('rejects non-base64 clipboard image payloads', async () => { + const dispatcher = makeDispatcher() + + const response = await dispatcher.dispatch( + makeRequest('clipboard.saveImageAsTempFile', { + contentBase64: 'not base64!' + }) + ) + + expect(response.ok).toBe(false) + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + }) + + it('accepts chunked uploads and forwards the recorded connectionId on commit', async () => { + saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png') + const dispatcher = makeDispatcher() + const contentBase64 = Buffer.from('png-bytes').toString('base64') + + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: contentBase64.length, + connectionId: 'ssh-1' + }) + ) + expect(start.ok).toBe(true) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + const firstChunk = contentBase64.slice(0, 4) + const secondChunk = contentBase64.slice(4) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: firstChunk + }) + ) + ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 4 } }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: firstChunk.length, + contentBase64: secondChunk + }) + ) + ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: contentBase64.length } }) + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: true, result: '/tmp/orca-paste-image.png' }) + expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), { + connectionId: 'ssh-1' + }) + }) + + it('rejects out-of-order chunk offsets', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 8, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + const response = await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 4, + contentBase64: 'AAAA' + }) + ) + + expect(response.ok).toBe(false) + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + }) + + it('rejects invalid base64 chunks and oversized chunks', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'not base64!' + }) + ) + ).resolves.toMatchObject({ ok: false }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4) + }) + ) + ).resolves.toMatchObject({ ok: false }) + }) + + it('rejects uploads beyond the existing total clipboard image limit', async () => { + const dispatcher = makeDispatcher() + + const response = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 24 * 1024 * 1024 + 1, + connectionId: null + }) + ) + + expect(response.ok).toBe(false) + }) + + it('rejects commit until all expected bytes arrive', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 8, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'AAAA' + }) + ) + + const response = await dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + + expect(response.ok).toBe(false) + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + }) + + it('validates the complete base64 payload before saving', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 8, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'AA==' + }) + ) + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 4, + contentBase64: 'AAAA' + }) + ) + + const response = await dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + + expect(response.ok).toBe(false) + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + }) + + it('deletes upload state after abort and treats repeated aborts as success', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: true, result: { aborted: true } }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: true, result: { aborted: true } }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: false }) + }) + + it('deletes upload state when saving fails during commit', async () => { + saveClipboardImageBufferAsTempFile.mockRejectedValue(new Error('ssh write failed')) + const dispatcher = makeDispatcher() + const contentBase64 = Buffer.from('png-bytes').toString('base64') + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: contentBase64.length, + connectionId: 'ssh-1' + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64 + }) + ) + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: false }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: false }) + expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledTimes(1) + }) + + it('bounds concurrent uploads and releases slots through TTL cleanup', async () => { + vi.useFakeTimers() + const dispatcher = makeDispatcher() + for (let index = 0; index < CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT; index++) { + await expect( + dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + ).resolves.toMatchObject({ ok: true }) + } + await expect( + dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + ).resolves.toMatchObject({ ok: false }) + + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + ).resolves.toMatchObject({ ok: true }) + }) +}) diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts new file mode 100644 index 00000000000..4c5568f4e25 --- /dev/null +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -0,0 +1,197 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' +import { randomUUID } from 'node:crypto' + +const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = 24 * 1024 * 1024 +export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 +export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 +const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +type ClipboardImageUpload = { + expectedBase64Length: number + connectionId?: string | null + chunks: string[] + receivedBase64Length: number + expiresAt: number + ttlTimer: ReturnType +} + +const clipboardImageUploads = new Map() + +function isValidBase64(value: string): boolean { + return value.length % 4 !== 1 && BASE64_PATTERN.test(value) +} + +function pruneExpiredUploads(now = Date.now()): void { + for (const [uploadId, upload] of clipboardImageUploads) { + if (upload.expiresAt <= now) { + deleteUpload(uploadId) + } + } +} + +function scheduleUploadExpiry(uploadId: string): ReturnType { + const timer = setTimeout(() => { + clipboardImageUploads.delete(uploadId) + }, CLIPBOARD_IMAGE_UPLOAD_TTL_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + return timer +} + +function refreshUploadExpiry(uploadId: string, upload: ClipboardImageUpload): void { + clearTimeout(upload.ttlTimer) + upload.expiresAt = Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS + upload.ttlTimer = scheduleUploadExpiry(uploadId) +} + +function deleteUpload(uploadId: string): void { + const upload = clipboardImageUploads.get(uploadId) + if (upload) { + clearTimeout(upload.ttlTimer) + } + clipboardImageUploads.delete(uploadId) +} + +function getUpload(uploadId: string): ClipboardImageUpload { + pruneExpiredUploads() + const upload = clipboardImageUploads.get(uploadId) + if (!upload) { + throw new Error('Clipboard image upload was not found') + } + return upload +} + +function assertValidBase64Content(value: string): void { + if (!isValidBase64(value)) { + throw new Error('Clipboard image content must be base64') + } +} + +const SaveImageAsTempFile = z.object({ + contentBase64: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing image content' }) + .refine((value) => value.length <= MAX_CLIPBOARD_IMAGE_BASE64_CHARS, { + message: 'Clipboard image is too large' + }) + .refine(isValidBase64, 'Clipboard image content must be base64'), + connectionId: z.string().min(1).nullable().optional() +}) + +const StartImageUpload = z.object({ + expectedBase64Length: z + .number() + .int() + .nonnegative() + .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, 'Clipboard image is too large'), + connectionId: z.string().min(1).nullable().optional() +}) + +const AppendImageUploadChunk = z.object({ + uploadId: z.string().min(1), + offset: z.number().int().nonnegative(), + contentBase64: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing image content' }) + .refine( + (value) => value.length <= CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, + 'Clipboard image chunk is too large' + ) + .refine(isValidBase64, 'Clipboard image content must be base64') +}) + +const CommitImageUpload = z.object({ + uploadId: z.string().min(1) +}) + +const AbortImageUpload = z.object({ + uploadId: z.string().min(1) +}) + +export const CLIPBOARD_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'clipboard.saveImageAsTempFile', + params: SaveImageAsTempFile, + handler: async (params) => + saveClipboardImageBufferAsTempFile(Buffer.from(params.contentBase64, 'base64'), { + connectionId: params.connectionId + }) + }), + defineMethod({ + name: 'clipboard.startImageUpload', + params: StartImageUpload, + handler: (params) => { + pruneExpiredUploads() + if (clipboardImageUploads.size >= CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT) { + throw new Error('Too many clipboard image uploads are in progress') + } + const uploadId = randomUUID() + clipboardImageUploads.set(uploadId, { + expectedBase64Length: params.expectedBase64Length, + connectionId: params.connectionId, + chunks: [], + receivedBase64Length: 0, + expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS, + ttlTimer: scheduleUploadExpiry(uploadId) + }) + return { uploadId } + } + }), + defineMethod({ + name: 'clipboard.appendImageUploadChunk', + params: AppendImageUploadChunk, + handler: (params) => { + const upload = getUpload(params.uploadId) + if (params.offset !== upload.receivedBase64Length) { + throw new Error('Clipboard image chunk offset is out of order') + } + const nextLength = upload.receivedBase64Length + params.contentBase64.length + if (nextLength > upload.expectedBase64Length) { + throw new Error('Clipboard image upload exceeded expected size') + } + upload.chunks.push(params.contentBase64) + upload.receivedBase64Length = nextLength + refreshUploadExpiry(params.uploadId, upload) + return { receivedBase64Length: upload.receivedBase64Length } + } + }), + defineMethod({ + name: 'clipboard.commitImageUpload', + params: CommitImageUpload, + handler: async (params) => { + const upload = getUpload(params.uploadId) + try { + if (upload.receivedBase64Length !== upload.expectedBase64Length) { + throw new Error('Clipboard image upload is incomplete') + } + const contentBase64 = upload.chunks.join('') + assertValidBase64Content(contentBase64) + return await saveClipboardImageBufferAsTempFile(Buffer.from(contentBase64, 'base64'), { + connectionId: upload.connectionId + }) + } finally { + // Why: failed SSH or filesystem commits must not leave bounded upload + // memory pinned until TTL cleanup. + deleteUpload(params.uploadId) + } + } + }), + defineMethod({ + name: 'clipboard.abortImageUpload', + params: AbortImageUpload, + handler: (params) => { + deleteUpload(params.uploadId) + return { aborted: true } + } + }) +] + +export function resetClipboardImageUploadsForTest(): void { + for (const uploadId of clipboardImageUploads.keys()) { + deleteUpload(uploadId) + } +} diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index d655a68a78e..20ca53b7b31 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -304,6 +304,24 @@ describe('git RPC methods', () => { expect(runtime.fetchRuntimeGit).toHaveBeenCalledWith('id:wt-1', pushTarget) }) + it('forwards fast-forward push target to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + fastForwardRuntimeGit: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + const pushTarget = { remoteName: 'fork', branchName: 'feature' } + + await dispatcher.dispatch( + makeRequest('git.fastForward', { + worktree: 'id:wt-1', + pushTarget + }) + ) + + expect(runtime.fastForwardRuntimeGit).toHaveBeenCalledWith('id:wt-1', pushTarget) + }) + it('forwards commit-message settings to the runtime', async () => { const commitMessageAi = { enabled: true, diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index ce770b1de38..4bc870eb1d7 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this table is the runtime git RPC contract; splitting it would make method coverage harder to audit. */ import { defineMethod, type RpcMethod } from '../core' import type { GlobalSettings } from '../../../../shared/types' import { @@ -108,6 +109,14 @@ export const GIT_METHODS: RpcMethod[] = [ ? runtime.pullRuntimeGit(params.worktree) : runtime.pullRuntimeGit(params.worktree, params.pushTarget) }), + defineMethod({ + name: 'git.fastForward', + params: GitTargetedRemote, + handler: async (params, { runtime }) => + params.pushTarget === undefined + ? runtime.fastForwardRuntimeGit(params.worktree) + : runtime.fastForwardRuntimeGit(params.worktree, params.pushTarget) + }), defineMethod({ name: 'git.rebaseFromBase', params: GitRebaseFromBase, diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index b7223989df4..37bf218a10f 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -371,6 +371,29 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it('sets PR auto-merge on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + setRepoPRAutoMerge: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.setPRAutoMerge', { + repo: 'repo-1', + prNumber: 7, + enabled: true, + prRepo: { owner: 'acme', repo: 'widgets' } + }) + ) + + expect(runtime.setRepoPRAutoMerge).toHaveBeenCalledWith('repo-1', 7, true, { + owner: 'acme', + repo: 'widgets' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('routes PR reviewer mutations on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index ebccf77bca0..ed48d4c6a53 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -123,6 +123,12 @@ const MergePr = RepoSelector.extend({ prRepo: SlugRepo.nullable().optional() }) +const SetPrAutoMerge = RepoSelector.extend({ + prNumber: z.number().int().positive(), + enabled: z.boolean(), + prRepo: SlugRepo.nullable().optional() +}) + const UpdatePrState = RepoSelector.extend({ prNumber: z.number().int().positive(), updates: z.object({ @@ -440,6 +446,17 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.mergeRepoPR(params.repo, params.prNumber, params.method, params.prRepo ?? null) }), + defineMethod({ + name: 'github.setPRAutoMerge', + params: SetPrAutoMerge, + handler: async (params, { runtime }) => + runtime.setRepoPRAutoMerge( + params.repo, + params.prNumber, + params.enabled, + params.prRepo ?? null + ) + }), defineMethod({ name: 'github.updatePRState', params: UpdatePrState, diff --git a/src/main/runtime/rpc/methods/host-capabilities.test.ts b/src/main/runtime/rpc/methods/host-capabilities.test.ts new file mode 100644 index 00000000000..9e5a6d23ca1 --- /dev/null +++ b/src/main/runtime/rpc/methods/host-capabilities.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' + +const { isPwshAvailable, isWslAvailable, listWslDistros } = vi.hoisted(() => ({ + isPwshAvailable: vi.fn(), + isWslAvailable: vi.fn(), + listWslDistros: vi.fn() +})) + +vi.mock('../../../pwsh', () => ({ isPwshAvailable })) +vi.mock('../../../wsl', () => ({ isWslAvailable, listWslDistros })) + +import { HOST_CAPABILITY_METHODS } from './host-capabilities' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('host capability RPC methods', () => { + beforeEach(() => { + isPwshAvailable.mockReset() + isWslAvailable.mockReset() + listWslDistros.mockReset() + }) + + it('reports Windows shell capability probes through explicit methods', async () => { + isPwshAvailable.mockReturnValue(true) + isWslAvailable.mockReturnValue(true) + listWslDistros.mockReturnValue(['Ubuntu']) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: HOST_CAPABILITY_METHODS }) + + await expect(dispatcher.dispatch(makeRequest('host.pwsh.isAvailable'))).resolves.toMatchObject({ + ok: true, + result: true + }) + await expect(dispatcher.dispatch(makeRequest('host.wsl.isAvailable'))).resolves.toMatchObject({ + ok: true, + result: true + }) + await expect(dispatcher.dispatch(makeRequest('host.wsl.listDistros'))).resolves.toMatchObject({ + ok: true, + result: ['Ubuntu'] + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/host-capabilities.ts b/src/main/runtime/rpc/methods/host-capabilities.ts new file mode 100644 index 00000000000..9d165cd7da2 --- /dev/null +++ b/src/main/runtime/rpc/methods/host-capabilities.ts @@ -0,0 +1,26 @@ +import { defineMethod, type RpcMethod } from '../core' +import { isPwshAvailable } from '../../../pwsh' +import { isWslAvailable, listWslDistros } from '../../../wsl' + +export const HOST_CAPABILITY_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'host.platform', + params: null, + handler: async () => ({ platform: process.platform }) + }), + defineMethod({ + name: 'host.wsl.isAvailable', + params: null, + handler: async () => isWslAvailable() + }), + defineMethod({ + name: 'host.wsl.listDistros', + params: null, + handler: async () => listWslDistros() + }), + defineMethod({ + name: 'host.pwsh.isAvailable', + params: null, + handler: async () => isPwshAvailable() + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 2d4002e79c3..00ffa4080bf 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -24,6 +24,8 @@ import { SSH_METHODS } from './ssh' import { SPEECH_METHODS } from './speech' import { CLIENT_UI_METHODS } from './client-ui' import { WORKSPACE_PORT_METHODS } from './workspace-ports' +import { CLIPBOARD_METHODS } from './clipboard' +import { HOST_CAPABILITY_METHODS } from './host-capabilities' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -53,5 +55,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...SSH_METHODS, ...SPEECH_METHODS, ...WORKSPACE_PORT_METHODS, + ...CLIPBOARD_METHODS, + ...HOST_CAPABILITY_METHODS, ...CLIENT_UI_METHODS ] diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index c1da8871be5..0861c11064c 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -119,10 +119,69 @@ describe('session tab RPC methods', () => { afterTabId: undefined, targetGroupId: 'group-left', command: 'zsh', + agent: undefined, activate: true }) }) + it('dispatches terminal creation with a requested agent preset', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createMobileSessionTerminal: vi.fn().mockResolvedValue({ + tab: { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'pty-1', + isActive: true + }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.createTerminal', { + worktree: 'id:wt-1', + agent: 'codex' + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.createMobileSessionTerminal).toHaveBeenCalledWith('id:wt-1', { + afterTabId: undefined, + targetGroupId: undefined, + command: undefined, + agent: 'codex', + activate: undefined + }) + }) + + it('rejects unknown agent presets without creating a terminal', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createMobileSessionTerminal: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.createTerminal', { + worktree: 'id:wt-1', + agent: 'not-real' + }) + ) + + expect(response.ok).toBe(false) + expect(response).toMatchObject({ + error: { code: 'invalid_argument', message: 'Unknown agent preset' } + }) + expect(runtime.createMobileSessionTerminal).not.toHaveBeenCalled() + }) + it('streams all known session tab snapshots and later updates', async () => { const unsubscribe = vi.fn() const listeners: ((snapshot: unknown) => void)[] = [] diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index eddfb846af5..9924a42e3aa 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -1,4 +1,6 @@ import { z } from 'zod' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/types' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' const WorktreeTabSelector = z.object({ @@ -19,6 +21,11 @@ const CreateTerminalTab = WorktreeTabSelector.extend({ afterTabId: z.string().optional(), targetGroupId: z.string().optional(), command: z.string().optional(), + agent: z + .custom(isTuiAgent, { + message: 'Unknown agent preset' + }) + .optional(), activate: z.boolean().optional() }) @@ -99,6 +106,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, command: params.command, + agent: params.agent, activate: params.activate }) }), diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8cc6486c641..4623c3cb021 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -140,6 +140,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'browser.screencast.unsubscribe', 'browser.tabCreate', 'browser.viewport', + 'clipboard.saveImageAsTempFile', 'files.createFile', 'files.list', 'files.open', @@ -153,6 +154,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'git.discard', 'git.diff', 'git.fetch', + 'git.fastForward', 'git.pull', 'git.push', 'git.rebaseFromBase', @@ -169,6 +171,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'github.listLabels', 'github.listWorkItems', 'github.mergePR', + 'github.setPRAutoMerge', 'github.requestPRReviewers', 'github.project.listAccessible', 'github.project.listAssignableUsersBySlug', @@ -208,6 +211,10 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'gitlab.updateMR', 'gitlab.updateMRState', 'gitlab.workItemDetails', + 'host.platform', + 'host.pwsh.isAvailable', + 'host.wsl.isAvailable', + 'host.wsl.listDistros', 'linear.getIssue', 'linear.addIssueComment', 'linear.connect', @@ -276,6 +283,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'worktree.activate', 'worktree.create', 'worktree.ps', + 'worktree.show', 'worktree.resolveMrBase', 'worktree.resolvePrBase', 'worktree.rm', diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index 4b1fe7a1c12..5b917f6ac4d 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -1,6 +1,10 @@ import type { App } from 'electron' import { describe, expect, it, vi } from 'vitest' -import { acquireSingleInstanceLock } from './single-instance-lock' +import { + acquireSingleInstanceLock, + logSingleInstanceLockFailure, + SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE +} from './single-instance-lock' type Listener = (...args: unknown[]) => void @@ -65,3 +69,14 @@ describe('acquireSingleInstanceLock', () => { expect(onSecondInstance).toHaveBeenCalledTimes(1) }) }) + +describe('logSingleInstanceLockFailure', () => { + it('emits a production-visible diagnostic for the early quit path', () => { + const logger = { error: vi.fn() } + + logSingleInstanceLockFailure(logger) + + expect(logger.error).toHaveBeenCalledWith(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE) + expect(logger.error.mock.calls[0]?.[0]).toContain('Electron/macOS single-instance lock failure') + }) +}) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 0227d454cc0..67a32c54ae5 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -1,5 +1,8 @@ import type { App } from 'electron' +export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = + '[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.' + /** * Why: Orca writes two canonical discovery files into `/`: * `orca-runtime.json` (RPC endpoint + authToken for the bundled CLI) and @@ -25,3 +28,7 @@ export function acquireSingleInstanceLock(app: App, onSecondInstance: () => void app.on('second-instance', onSecondInstance) return true } + +export function logSingleInstanceLockFailure(logger: Pick = console): void { + logger.error(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE) +} diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index 96f50bcd044..b7b91335132 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -5,9 +5,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment' const originalEnv = { ...process.env } +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const tempDirs: string[] = [] afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } for (const key of Object.keys(process.env)) { if (!(key in originalEnv)) { delete process.env[key] @@ -20,6 +24,7 @@ afterEach(() => { }) function makeHome(): string { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) const dir = mkdtempSync(join(tmpdir(), 'orca-commit-env-')) tempDirs.push(dir) process.env.HOME = dir @@ -40,7 +45,7 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true, env: expect.objectContaining({ - OPENCODE_CONFIG_DIR: join(home, 'company/opencode') + OPENCODE_CONFIG_DIR: `${home}/company/opencode` }) }) }) @@ -69,7 +74,7 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true, env: expect.objectContaining({ - PI_CODING_AGENT_DIR: join(home, '.config/pi-agent') + PI_CODING_AGENT_DIR: `${home}/.config/pi-agent` }) }) }) @@ -104,4 +109,29 @@ describe('prepareLocalCommitMessageAgentEnv', () => { ok: true }) }) + + it('sets CODEX_HOME for host managed Codex accounts', async () => { + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => + 'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home' + }) + + expect(result).toEqual({ + ok: true, + env: expect.objectContaining({ + CODEX_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home' + }) + }) + }) + + it('does not pass WSL managed Codex homes to host-local commit generation', async () => { + process.env.CODEX_HOME = 'C:\\Users\\tester\\.codex' + + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => + '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.local\\share\\orca\\codex-accounts\\a\\home' + }) + + expect(result).toEqual({ ok: true }) + }) }) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 5d4558b5cca..25dbdd8ccc1 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -1,6 +1,7 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { readShellStartupEnvVar } from '../pty/shell-startup-env' +import { parseWslUncPath } from '../../shared/wsl-paths' export type CommitMessageAgentEnvironmentResolvers = { prepareForCodexLaunch?: () => string | null @@ -74,6 +75,11 @@ export async function prepareLocalCommitMessageAgentEnv( try { if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { const codexHomePath = resolvers.prepareForCodexLaunch() + if (codexHomePath && parseWslUncPath(codexHomePath)) { + // Why: this local generation path spawns the host Codex binary. A WSL + // managed home is only valid when the process is routed through wsl.exe. + return { ok: true } + } return { ok: true, env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index d267ce15375..14704c7847b 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -610,6 +610,42 @@ describe('generateCommitMessageFromContext', () => { }) }) + it('reports empty remote commit-message output as an empty message', async () => { + let operation = '' + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async (_plan, _cwd, _timeoutMs, requestedOperation) => { + operation = requestedOperation + return { + stdout: ' \n', + stderr: '', + exitCode: 0, + timedOut: false + } + } + } + ) + + expect(operation).toBe('commit-message') + expect(result).toEqual({ + success: false, + error: 'agent returned an empty message.' + }) + }) + it('sanitizes remote execution transport failures', async () => { const result = await generateCommitMessageFromContext( { @@ -813,6 +849,130 @@ describe('generateCommitMessageFromContext', () => { expect(children[1]?.kill).not.toHaveBeenCalled() }) + it('keeps local pull-request cancellation from stopping commit-message generation', async () => { + const children: { + kill: ReturnType + listeners: Map void> + }[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 123 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push({ kill: child.kill, listeners }) + return child as never + }) + + const commit = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + const pullRequest = generatePullRequestFieldsFromContext( + { + branch: 'feature/pr-fields', + base: 'main', + branchChangedByPreparation: false, + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: '- feat: update README', + changeSummary: 'M\tREADME.md', + patch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + + cancelGeneratePullRequestFieldsLocal('/repo') + + expect(children[0]?.kill).not.toHaveBeenCalled() + expect(children[1]?.kill).toHaveBeenCalledWith('SIGKILL') + + const commitStdout = children[0]?.listeners.get('stdout:data') + commitStdout?.(Buffer.from('Update README\n')) + children[0]?.listeners.get('close')?.(0) + children[1]?.listeners.get('close')?.(null) + + await expect(commit).resolves.toEqual({ + success: true, + message: 'Update README', + agentLabel: 'agent' + }) + await expect(pullRequest).resolves.toEqual({ + success: false, + error: 'Generation canceled.', + canceled: true, + branchChangedByPreparation: false + }) + }) + + it('reports empty remote pull-request field output as empty details', async () => { + let operation = '' + const result = await generatePullRequestFieldsFromContext( + { + branch: 'feature/pr-fields', + base: 'main', + branchChangedByPreparation: false, + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: '- feat: update README', + changeSummary: 'M\tREADME.md', + patch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async (_plan, _cwd, _timeoutMs, requestedOperation) => { + operation = requestedOperation + return { + stdout: ' \n', + stderr: '', + exitCode: 0, + timedOut: false + } + } + } + ) + + expect(operation).toBe('pull-request-fields') + expect(result).toEqual({ + success: false, + error: 'agent returned an empty details.', + branchChangedByPreparation: false + }) + }) + it('reports branch changes when pull request field output cannot be parsed', async () => { const listeners = new Map void>() spawnMock.mockReturnValue({ diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index c56d309b50c..4bb5192ae80 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -84,6 +84,8 @@ export type RemoteCommitMessageExecResult = { spawnError?: string } +export type TextGenerationOperation = 'commit-message' | 'pull-request-fields' | 'branch-name' + export type CommitMessageGenerationTarget = | { kind: 'local'; cwd: string; env?: NodeJS.ProcessEnv } | { @@ -92,7 +94,8 @@ export type CommitMessageGenerationTarget = execute: ( plan: CommitMessagePlan, cwd: string, - timeoutMs: number + timeoutMs: number, + operation: TextGenerationOperation ) => Promise missingBinaryLocation: string } @@ -428,13 +431,11 @@ function killProcessTree(child: ChildProcess): void { } } -type LocalGenerationOperation = 'commit-message' | 'pull-request-fields' | 'branch-name' - // Keying by operation plus `local:${cwd}` keeps local cancellation independent // from SSH worktrees and from other generation features in the same worktree. const cancelTokensByLane = new Map void>() -function localLaneKey(operation: LocalGenerationOperation, cwd: string): string { +function localLaneKey(operation: TextGenerationOperation, cwd: string): string { return `${operation}:local:${cwd}` } @@ -447,7 +448,7 @@ async function runLocalPlan( cwd: string, env: NodeJS.ProcessEnv | undefined, emptyResultName = 'message', - operation: LocalGenerationOperation = 'commit-message' + operation: TextGenerationOperation = 'commit-message' ): Promise { const { binary, args, stdinPayload, label } = plan return new Promise((resolve) => { @@ -621,12 +622,13 @@ function finalizeFromAgentOutput(args: { async function runRemotePlan( plan: CommitMessagePlan, target: Extract, - emptyResultName = 'message' + emptyResultName = 'message', + operation: TextGenerationOperation = 'commit-message' ): Promise { const { binary, label } = plan let result: RemoteCommitMessageExecResult try { - result = await target.execute(plan, target.cwd, GENERATION_TIMEOUT_MS) + result = await target.execute(plan, target.cwd, GENERATION_TIMEOUT_MS, operation) } catch (error) { console.error('[commit-message] Remote generator request failed:', error) return { @@ -709,8 +711,8 @@ export async function generateCommitMessageFromContext( const internalResult = target.kind === 'remote' - ? await runRemotePlan(planned.plan, target, 'details') - : await runLocalPlan(planned.plan, target.cwd, target.env, 'details') + ? await runRemotePlan(planned.plan, target) + : await runLocalPlan(planned.plan, target.cwd, target.env) return formatCommitMessageGenerationResult(internalResult) } @@ -761,7 +763,7 @@ export async function generatePullRequestFieldsFromContext( const internalResult = target.kind === 'remote' - ? await runRemotePlan(planned.plan, target) + ? await runRemotePlan(planned.plan, target, 'details', 'pull-request-fields') : await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields') return formatPullRequestFieldsGenerationResult(internalResult, context) } @@ -788,7 +790,7 @@ export async function generateBranchNameFromContext( const internalResult = target.kind === 'remote' - ? await runRemotePlan(planned.plan, target, 'branch name') + ? await runRemotePlan(planned.plan, target, 'branch name', 'branch-name') : await runLocalPlan(planned.plan, target.cwd, target.env, 'branch name', 'branch-name') if (!internalResult.success) { return internalResult diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index c982d3361a6..8bc36c3343b 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -33,14 +33,19 @@ import type { RuntimeMarkdownSaveTabResult } from '../../shared/mobile-markdown-document' import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null, - prepareClaudeAuth?: () => Promise, + getSelectedCodexHomePath?: (target?: CodexAccountSelectionTarget) => string | null, + prepareClaudeAuth?: ( + target?: ClaudeAccountSelectionTarget + ) => Promise, options?: { onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void } @@ -369,25 +374,15 @@ function registerRuntimeWindowLifecycle( function registerFileDropRelay(mainWindow: BrowserWindow): void { ipcMain.removeAllListeners('terminal:file-dropped-from-preload') - ipcMain.on( - 'terminal:file-dropped-from-preload', - ( - _event, - args: - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - ) => { - if (mainWindow.isDestroyed()) { - return - } - - // Why: relay exactly one IPC event per drop gesture so the renderer - // receives the full batch of paths without timer-based reconstruction. - mainWindow.webContents.send('terminal:file-drop', args) + ipcMain.on('terminal:file-dropped-from-preload', (_event, args: NativeFileDropPayload) => { + if (mainWindow.isDestroyed()) { + return } - ) + + // Why: relay exactly one IPC event per drop gesture so the renderer + // receives the full batch of paths without timer-based reconstruction. + mainWindow.webContents.send('terminal:file-drop', args) + }) } export function registerUpdaterHandlers(_store: Store): void { diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts new file mode 100644 index 00000000000..b52710014c8 --- /dev/null +++ b/src/main/window/clipboard-image-temp-file.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { randomUUID } from 'node:crypto' + +import { app } from 'electron' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +export type SaveClipboardImageAsTempFileArgs = { + connectionId?: string | null +} + +const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp' + +function joinRemotePath(basePath: string, fileName: string): string { + if (isWindowsAbsolutePathLike(basePath)) { + return path.win32.join(basePath, fileName) + } + return path.posix.join(basePath, fileName) +} + +export async function saveClipboardImageBufferAsTempFile( + buffer: Buffer, + args?: SaveClipboardImageAsTempFileArgs +): Promise { + const fileName = `orca-paste-${Date.now()}-${randomUUID()}.png` + + if (args?.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const remoteTempDir = (await provider.getTempDir?.()) ?? REMOTE_CLIPBOARD_IMAGE_TEMP_DIR + const remotePath = joinRemotePath(remoteTempDir, fileName) + // Why: SSH terminal agents run on the remote host, so the pasted path must + // name a remote file. The provider's base64 path writes binary bytes via SFTP. + await provider.writeFileBase64(remotePath, buffer.toString('base64')) + return remotePath + } + + const tempPath = path.join(app.getPath('temp'), fileName) + await fs.writeFile(tempPath, buffer) + return tempPath +} diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index fb82a4ac80c..c6b849b08ed 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -1,44 +1,8 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { randomUUID } from 'node:crypto' - -import { app, clipboard, ipcMain, nativeImage } from 'electron' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' - -type SaveClipboardImageAsTempFileArgs = { - connectionId?: string | null -} - -const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp' - -function joinRemotePath(basePath: string, fileName: string): string { - if (isWindowsAbsolutePathLike(basePath)) { - return path.win32.join(basePath, fileName) - } - return path.posix.join(basePath, fileName) -} - -async function saveClipboardImageBufferAsTempFile( - buffer: Buffer, - args?: SaveClipboardImageAsTempFileArgs -): Promise { - const fileName = `orca-paste-${Date.now()}-${randomUUID()}.png` - - if (args?.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const remoteTempDir = (await provider.getTempDir?.()) ?? REMOTE_CLIPBOARD_IMAGE_TEMP_DIR - const remotePath = joinRemotePath(remoteTempDir, fileName) - // Why: SSH terminal agents run on the remote host, so the pasted path must - // name a remote file. The provider's base64 path writes binary bytes via SFTP. - await provider.writeFileBase64(remotePath, buffer.toString('base64')) - return remotePath - } - - const tempPath = path.join(app.getPath('temp'), fileName) - await fs.writeFile(tempPath, buffer) - return tempPath -} +import { clipboard, ipcMain, nativeImage } from 'electron' +import { + saveClipboardImageBufferAsTempFile, + type SaveClipboardImageAsTempFileArgs +} from './clipboard-image-temp-file' export function registerClipboardHandlers(): void { ipcMain.removeHandler('clipboard:readText') diff --git a/src/main/wsl-bash-command.test.ts b/src/main/wsl-bash-command.test.ts new file mode 100644 index 00000000000..a6440a7fd8d --- /dev/null +++ b/src/main/wsl-bash-command.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { buildEncodedWslBashCommand } from './wsl-bash-command' + +describe('buildEncodedWslBashCommand', () => { + it('wraps Bash scripts without exposing local shell variables to wsl.exe', () => { + const command = [ + 'set -euo pipefail', + "candidate='/home/alice/.local/share/orca/codex-accounts/a/home'", + 'candidate_real=$(readlink -f -- "$candidate")', + 'printf "%s\\n" "$candidate_real"' + ].join('\n') + + const wrapped = buildEncodedWslBashCommand(command) + const encoded = wrapped.match( + /^set -o pipefail; printf %s '([^']+)' \| base64 -d \| bash$/ + )?.[1] + + expect(wrapped).not.toContain('$candidate') + expect(wrapped).not.toContain('\n') + expect(encoded).toBeTruthy() + expect(Buffer.from(encoded as string, 'base64').toString('utf8')).toBe(command) + }) +}) diff --git a/src/main/wsl-bash-command.ts b/src/main/wsl-bash-command.ts new file mode 100644 index 00000000000..ff7426ae6ec --- /dev/null +++ b/src/main/wsl-bash-command.ts @@ -0,0 +1,10 @@ +function quoteShell(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +export function buildEncodedWslBashCommand(command: string): string { + // Why: wsl.exe preprocesses `$local_shell_vars` in command arguments before + // Bash sees them. Base64 keeps validation scripts intact across that boundary. + const encoded = Buffer.from(command, 'utf8').toString('base64') + return `set -o pipefail; printf %s ${quoteShell(encoded)} | base64 -d | bash` +} diff --git a/src/main/wsl-env.ts b/src/main/wsl-env.ts new file mode 100644 index 00000000000..71e836e945b --- /dev/null +++ b/src/main/wsl-env.ts @@ -0,0 +1,17 @@ +export function addWslEnvKeys( + env: Record, + keys: readonly string[] +): void { + const existing = env.WSLENV ?? process.env.WSLENV ?? '' + const tokens = existing.split(':').filter(Boolean) + const tokenNames = new Set(tokens.map((token) => token.split('/')[0])) + + for (const key of keys) { + if (!tokenNames.has(key)) { + tokens.push(key) + tokenNames.add(key) + } + } + + env.WSLENV = tokens.join(':') +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8f458b78e94..21f4ac13922 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -7,6 +7,7 @@ import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../shared/hosted-review' +import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { AppIdentity } from '../shared/app-identity' import type { BaseRefDefaultResult, @@ -224,7 +225,7 @@ import type { ClaudeUsageSessionRow, ClaudeUsageSummary } from '../shared/claude-usage-types' -import type { RateLimitState } from '../shared/rate-limit-types' +import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -272,6 +273,7 @@ import type { } from '../shared/opencode-usage-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' +import type { AppStarSource } from '../shared/gh-star-source' import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, @@ -424,9 +426,16 @@ export type RefreshAgentsResult = { } export type PreflightApi = { - check: (args?: { force?: boolean; wslDistro?: string | null }) => Promise - detectAgents: (args?: { wslDistro?: string | null }) => Promise - refreshAgents: (args?: { wslDistro?: string | null }) => Promise + check: (args?: { + force?: boolean + wslDistro?: string | null + wslDefault?: boolean + }) => Promise + detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }) => Promise + refreshAgents: (args?: { + wslDistro?: string | null + wslDefault?: boolean + }) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise } @@ -997,6 +1006,13 @@ export type PreloadApi = { method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null }) => Promise<{ ok: true } | { ok: false; error: string }> + setPRAutoMerge: (args: { + repoPath: string + repoId?: string + prNumber: number + enabled: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise<{ ok: true } | { ok: false; error: string }> updatePRState: (args: { repoPath: string repoId?: string @@ -1065,7 +1081,7 @@ export type PreloadApi = { }) => void ) => () => void checkOrcaStarred: () => Promise - starOrca: () => Promise + starOrca: (source: AppStarSource) => Promise /** * GitHub API rate-limit snapshot. Does NOT consume quota (the * `rate_limit` endpoint is exempt). Cached 30s server-side — pass @@ -1329,17 +1345,31 @@ export type PreloadApi = { } codexAccounts: { list: () => Promise - add: () => Promise + add: (args?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise reauthenticate: (args: { accountId: string }) => Promise remove: (args: { accountId: string }) => Promise - select: (args: { accountId: string | null }) => Promise + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise } claudeAccounts: { list: () => Promise - add: () => Promise + add: (args?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise reauthenticate: (args: { accountId: string }) => Promise remove: (args: { accountId: string }) => Promise - select: (args: { accountId: string | null }) => Promise + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise } cli: { getInstallStatus: () => Promise @@ -1351,9 +1381,11 @@ export type PreloadApi = { } agentHooks: { claudeStatus: () => Promise + openClaudeStatus: () => Promise codexStatus: () => Promise geminiStatus: () => Promise antigravityStatus: () => Promise + ampStatus: () => Promise cursorStatus: () => Promise droidStatus: () => Promise commandCodeStatus: () => Promise @@ -1655,6 +1687,11 @@ export type PreloadApi = { connectionId?: string pushTarget?: GitPushTarget }) => Promise + fastForward: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }) => Promise rebaseFromBase: (args: { worktreePath: string baseRef: string @@ -1916,15 +1953,7 @@ export type PreloadApi = { writeClipboardText: (text: string) => Promise writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise - onFileDrop: ( - callback: ( - data: - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - ) => void - ) => () => void + onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void syncTrafficLights: (zoomFactor: number) => void @@ -2017,6 +2046,8 @@ export type PreloadApi = { rateLimits: { get: () => Promise refresh: () => Promise + refreshCodexForTarget: (target: RateLimitRuntimeTarget) => Promise + refreshClaudeForTarget: (target: RateLimitRuntimeTarget) => Promise setPollingInterval: (ms: number) => Promise fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise @@ -2101,6 +2132,7 @@ export type PreloadApi = { } wsl: { isAvailable: () => Promise + listDistros: () => Promise } pwsh: { isAvailable: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index f8b7a9f3bb6..c782e00adbe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,7 +55,7 @@ import type { RuntimeMobileMarkdownRequest, RuntimeMobileMarkdownResponse } from '../shared/mobile-markdown-document' -import type { RateLimitState } from '../shared/rate-limit-types' +import type { RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' import type { WorkspaceSpaceAnalyzeResult, WorkspaceSpaceScanProgress @@ -120,6 +120,7 @@ import type { import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { RefreshAgentsResult } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' +import type { AppStarSource } from '../shared/gh-star-source' import type { Automation, AutomationCreateInput, @@ -145,27 +146,20 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import { + NATIVE_FILE_DROP_TARGET, + ORCA_INTERNAL_FILE_DRAG_TYPE, + hasNativeFileDragTypes, + resolveNativeFileDropPath, + type NativeDropResolution, + type NativeFileDropPayload, + type NativeFileDropPathEntry +} from '../shared/native-file-drop' import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' import type { CrashReportSubmitArgs, CrashReportSubmitResult } from '../shared/crash-reporting' -type NativeDropResolution = - | { target: 'editor' } - | { target: 'terminal'; tabId?: string } - | { target: 'composer' } - | { target: 'file-explorer'; destinationDir: string } - // Why: returned when the explorer marker was found but no destinationDir - // could be resolved. The caller must suppress the drop entirely instead of - // falling back to 'editor' — fail-closed behavior per design §7.1. - | { target: 'rejected' } - -type NativeFileDropPayload = - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] @@ -297,43 +291,17 @@ function disposeCachedNotificationSound(): void { * "inside this folder". */ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { - const path = event.composedPath() - let foundExplorer = false - let destinationDir: string | undefined - - for (const entry of path) { - if (!(entry instanceof HTMLElement)) { - continue - } - - const target = entry.dataset.nativeFileDropTarget - if (target === 'terminal') { - return { target, tabId: entry.dataset.terminalTabId } - } - if (target === 'editor' || target === 'composer') { - return { target } - } - if (target === 'file-explorer') { - foundExplorer = true - } - - // Pick the nearest (innermost) destination directory marker - if (destinationDir === undefined && entry.dataset.nativeFileDropDir) { - destinationDir = entry.dataset.nativeFileDropDir + const pathEntries: NativeFileDropPathEntry[] = [] + for (const entry of event.composedPath()) { + if (entry instanceof HTMLElement) { + pathEntries.push({ + nativeFileDropTarget: entry.dataset.nativeFileDropTarget, + nativeFileDropDir: entry.dataset.nativeFileDropDir, + terminalTabId: entry.dataset.terminalTabId + }) } } - - if (foundExplorer) { - // Why: routing must fail closed for explorer drops. If preload sees the - // explorer target marker but cannot resolve a destinationDir, it rejects - // the gesture and emits no fallback editor drop event. - if (!destinationDir) { - return { target: 'rejected' } - } - return { target: 'file-explorer', destinationDir } - } - - return null + return resolveNativeFileDropPath(pathEntries) } // --------------------------------------------------------------------------- @@ -346,7 +314,7 @@ document.addEventListener( (e) => { // Let in-app drags (e.g. file explorer drag-to-move) through to React handlers // so they can set their own dropEffect. Only override for native OS file drops. - if (e.dataTransfer?.types.includes('text/x-orca-file-path')) { + if (e.dataTransfer && !hasNativeFileDragTypes(e.dataTransfer.types)) { return } e.preventDefault() @@ -361,7 +329,7 @@ document.addEventListener( 'drop', (e) => { // Let in-app drags (e.g. file explorer → terminal) through to React handlers - if (e.dataTransfer?.types.includes('text/x-orca-file-path')) { + if (e.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { return } @@ -397,20 +365,20 @@ document.addEventListener( // The preload layer already has the full FileList. Re-emitting one IPC // message per path and asking the renderer to reconstruct the gesture via // timing would be both fragile and slower under large drops. - if (resolution?.target === 'file-explorer') { + if (resolution?.target === NATIVE_FILE_DROP_TARGET.fileExplorer) { ipcRenderer.send('terminal:file-dropped-from-preload', { paths, - target: 'file-explorer', + target: NATIVE_FILE_DROP_TARGET.fileExplorer, destinationDir: resolution.destinationDir }) } else { // Why: falls back to 'editor' so drops on surfaces without an explicit - // marker (sidebar, editor body, etc.) preserve the prior open-in-editor - // behavior instead of being silently discarded. + // marker preserve the prior open-in-editor behavior instead of being + // silently discarded. ipcRenderer.send('terminal:file-dropped-from-preload', { paths, - target: resolution?.target ?? 'editor', - ...(resolution?.target === 'terminal' && resolution.tabId + target: resolution?.target ?? NATIVE_FILE_DROP_TARGET.editor, + ...(resolution?.target === NATIVE_FILE_DROP_TARGET.terminal && resolution.tabId ? { tabId: resolution.tabId } : {}) }) @@ -461,7 +429,8 @@ const api = { }, wsl: { - isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable') + isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), + listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') }, pwsh: { @@ -1072,6 +1041,15 @@ const api = { }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args), + setPRAutoMerge: (args: { + repoPath: string + repoId?: string + prNumber: number + enabled: boolean + prRepo?: { owner: string; repo: string } | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:setPRAutoMerge', args), + updatePRState: (args: { repoPath: string repoId?: string @@ -1163,7 +1141,8 @@ const api = { }, checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca'), + starOrca: (source: AppStarSource): Promise => + ipcRenderer.invoke('gh:starOrca', source), // Why: rate_limit is exempt from rate-limit accounting, but we still pass // `force` through so callers can bust the 30s in-process cache after a @@ -1412,24 +1391,32 @@ const api = { codexAccounts: { list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (): Promise => ipcRenderer.invoke('codexAccounts:add'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('codexAccounts:add', args), reauthenticate: (args: { accountId: string }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => ipcRenderer.invoke('codexAccounts:remove', args), - select: (args: { accountId: string | null }): Promise => - ipcRenderer.invoke('codexAccounts:select', args) + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('codexAccounts:select', args) }, claudeAccounts: { list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (): Promise => ipcRenderer.invoke('claudeAccounts:add'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('claudeAccounts:add', args), reauthenticate: (args: { accountId: string }): Promise => ipcRenderer.invoke('claudeAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => ipcRenderer.invoke('claudeAccounts:remove', args), - select: (args: { accountId: string | null }): Promise => - ipcRenderer.invoke('claudeAccounts:select', args) + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) }, cli: { @@ -1445,12 +1432,15 @@ const api = { agentHooks: { claudeStatus: (): Promise => ipcRenderer.invoke('agentHooks:claudeStatus'), + openClaudeStatus: (): Promise => + ipcRenderer.invoke('agentHooks:openClaudeStatus'), codexStatus: (): Promise => ipcRenderer.invoke('agentHooks:codexStatus'), geminiStatus: (): Promise => ipcRenderer.invoke('agentHooks:geminiStatus'), antigravityStatus: (): Promise => ipcRenderer.invoke('agentHooks:antigravityStatus'), + ampStatus: (): Promise => ipcRenderer.invoke('agentHooks:ampStatus'), cursorStatus: (): Promise => ipcRenderer.invoke('agentHooks:cursorStatus'), droidStatus: (): Promise => @@ -1495,10 +1485,12 @@ const api = { } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: { wslDistro?: string | null }): Promise => + detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }): Promise => ipcRenderer.invoke('preflight:detectAgents', args), - refreshAgents: (args?: { wslDistro?: string | null }): Promise => - ipcRenderer.invoke('preflight:refreshAgents', args), + refreshAgents: (args?: { + wslDistro?: string | null + wslDefault?: boolean + }): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args) }, @@ -2260,6 +2252,11 @@ const api = { connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:pull', args), + fastForward: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:fastForward', args), rebaseFromBase: (args: { worktreePath: string baseRef: string @@ -3056,6 +3053,10 @@ const api = { rateLimits: { get: (): Promise => ipcRenderer.invoke('rateLimits:get'), refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), + refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), + refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), setPollingInterval: (ms: number): Promise => ipcRenderer.invoke('rateLimits:setPollingInterval', ms), fetchInactiveClaudeAccounts: (): Promise => diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index da402b7d193..edf9c57bcbf 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -253,6 +253,72 @@ describe('AgentExecHandler', () => { }) }) + it('cancels only the matching operation lane for a cwd', async () => { + const commitChild = createFakeChild() + const pullRequestChild = createFakeChild() + pullRequestChild.pid = 12346 + spawnMock + .mockReturnValueOnce(commitChild as never) + .mockReturnValueOnce(pullRequestChild as never) + const handlers = createHandlers() + + const commit = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: [], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000, + operation: 'commit-message' + }, + requestContext() + ) + const pullRequest = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: [], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000, + operation: 'pull-request-fields' + }, + requestContext() + ) + + await expect( + handlers.get('agent.cancelExec')!( + { cwd: '/repo', operation: 'commit-message' }, + requestContext() + ) + ).resolves.toEqual({ canceled: true }) + + if (process.platform === 'win32') { + expect(execMock).toHaveBeenCalledWith('taskkill /pid 12345 /T /F', expect.any(Function)) + expect(execMock).not.toHaveBeenCalledWith('taskkill /pid 12346 /T /F', expect.any(Function)) + } else { + expect(commitChild.kill).toHaveBeenCalledWith('SIGKILL') + expect(pullRequestChild.kill).not.toHaveBeenCalled() + } + + commitChild.emit('close', null) + pullRequestChild.stdout.emit( + 'data', + Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}') + ) + pullRequestChild.emit('close', 0) + + await expect(commit).resolves.toMatchObject({ + exitCode: null, + timedOut: false, + canceled: true + }) + await expect(pullRequest).resolves.toMatchObject({ + exitCode: 0, + timedOut: false, + canceled: false + }) + }) + it('reports when cancellation has no matching in-flight command', async () => { const handlers = createHandlers() diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index 38dc75601a4..2a0ca4ae61a 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -94,12 +94,21 @@ type ExecParams = { stdin: unknown timeoutMs: unknown env: unknown + operation: unknown } type CancelParams = { cwd: unknown + operation: unknown } +function laneKeyFor(cwd: string, operation: unknown): string { + const op = typeof operation === 'string' && operation ? operation : 'default' + return JSON.stringify([op, cwd]) +} + +type InFlightExec = { child: ChildProcess; markCanceled: () => void } + type ExecResult = { stdout: string stderr: string @@ -119,10 +128,13 @@ type ExecResult = { * and a clean exit code instead of an interactive session. */ export class AgentExecHandler { - // Why: a single in-flight exec per cwd is enough — the renderer prevents - // double-clicks. Keying by cwd lets `agent.cancelExec({cwd})` find the - // child without the client having to track relay-internal request ids. - private inFlightByCwd = new Map void }>() + // Why: commit-message and PR-field generation can run together for one cwd; + // operation lanes let cancel target only the user-visible job that stopped. + private inFlightByLane = new Map() + + private laneKey(cwd: string, operation: unknown): string { + return laneKeyFor(cwd, operation) + } constructor(dispatcher: RelayDispatcher) { dispatcher.onRequest('agent.execNonInteractive', (p) => this.exec(p as ExecParams)) @@ -131,7 +143,7 @@ export class AgentExecHandler { private async cancel(params: CancelParams): Promise<{ canceled: boolean }> { const cwd = typeof params.cwd === 'string' ? params.cwd : '' - const entry = this.inFlightByCwd.get(cwd) + const entry = this.inFlightByLane.get(this.laneKey(cwd, params.operation)) if (!entry) { return { canceled: false } } @@ -185,24 +197,26 @@ export class AgentExecHandler { let timedOut = false let canceled = false let settled = false - const laneKey = typeof cwd === 'string' ? cwd : '' + const laneKey = typeof cwd === 'string' ? this.laneKey(cwd, params.operation) : '' + let entry: InFlightExec | null = null const finish = (result: ExecResult): void => { if (settled) { return } settled = true - if (laneKey) { - this.inFlightByCwd.delete(laneKey) + if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) { + this.inFlightByLane.delete(laneKey) } resolve(result) } if (laneKey) { - this.inFlightByCwd.set(laneKey, { + entry = { child, markCanceled: () => { canceled = true } - }) + } + this.inFlightByLane.set(laneKey, entry) } const timer = setTimeout(() => { diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 2f0680d91e6..326e42059a4 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -56,6 +56,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.fetchRemoteTrackingRef') expect(methods).toContain('git.push') expect(methods).toContain('git.pull') + expect(methods).toContain('git.fastForward') expect(methods).toContain('git.rebaseFromBase') expect(methods).toContain('git.branchDiff') expect(methods).toContain('git.listWorktrees') @@ -447,6 +448,37 @@ describe('GitHandler', () => { await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() }) + it('treats untracked discard paths with Git glob characters as literal paths', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, '.gitignore'), 'ignored.log\n') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, '*.log'), 'selected') + writeFileSync(path.join(tmpDir, 'keep.log'), 'unrelated') + writeFileSync(path.join(tmpDir, 'ignored.log'), 'ignored') + + await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' }) + + await expect(fs.access(path.join(tmpDir, '*.log'))).rejects.toThrow() + await expect(fs.access(path.join(tmpDir, 'keep.log'))).resolves.toBeUndefined() + await expect(fs.access(path.join(tmpDir, 'ignored.log'))).resolves.toBeUndefined() + }) + + it('treats tracked discard paths with Git glob characters as literal paths', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, '*.log'), 'selected') + writeFileSync(path.join(tmpDir, 'keep.log'), 'keep') + gitCommit(tmpDir, 'track log fixtures') + writeFileSync(path.join(tmpDir, '*.log'), 'selected modified') + writeFileSync(path.join(tmpDir, 'keep.log'), 'keep modified') + + await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' }) + + await expect(fs.readFile(path.join(tmpDir, '*.log'), 'utf-8')).resolves.toBe('selected') + await expect(fs.readFile(path.join(tmpDir, 'keep.log'), 'utf-8')).resolves.toBe( + 'keep modified' + ) + }) + it('bulk discards tracked and untracked files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'a.txt'), 'a') @@ -832,6 +864,54 @@ describe('GitHandler', () => { } }) + it('fast-forwards the tracked branch with ff-only pull semantics', async () => { + const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) + const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-')) + const producerDir = path.join(producerParent, 'repo') + try { + execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) + + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['remote', 'add', 'origin', bareDir], { + cwd: tmpDir, + stdio: 'pipe' + }) + execFileSync('git', ['push', '--set-upstream', 'origin', branch], { + cwd: tmpDir, + stdio: 'pipe' + }) + + execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: producerDir, + stdio: 'pipe' + }) + execFileSync('git', ['config', 'user.name', 'Test'], { + cwd: producerDir, + stdio: 'pipe' + }) + writeFileSync(path.join(producerDir, 'remote.txt'), 'remote') + gitCommit(producerDir, 'remote commit') + execFileSync('git', ['push', 'origin', branch], { + cwd: producerDir, + stdio: 'pipe' + }) + + await dispatcher.callRequest('git.fastForward', { worktreePath: tmpDir }) + + await expect(fs.readFile(path.join(tmpDir, 'remote.txt'), 'utf-8')).resolves.toBe('remote') + } finally { + await fs.rm(bareDir, { recursive: true, force: true }) + await fs.rm(producerParent, { recursive: true, force: true }) + } + }) + it('refreshes one remote-tracking ref from a configured remote', async () => { const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-')) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 62ee18269b7..b532ab070ff 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -76,6 +76,7 @@ export class GitHandler { this.dispatcher.onRequest('git.fetchRemoteTrackingRef', (p) => this.fetchRemoteTrackingRef(p)) this.dispatcher.onRequest('git.push', (p) => this.push(p)) this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) + this.dispatcher.onRequest('git.fastForward', (p) => this.fastForward(p)) this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) @@ -235,14 +236,20 @@ export class GitHandler { let tracked = false try { - await this.git(['ls-files', '--error-unmatch', '--', filePath], worktreePath) + await this.git( + ['ls-files', '--error-unmatch', '--', this.literalPathspec(filePath)], + worktreePath + ) tracked = true } catch { // untracked } if (tracked) { - await this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) + await this.git( + ['restore', '--worktree', '--source=HEAD', '--', this.literalPathspec(filePath)], + worktreePath + ) return } @@ -265,7 +272,10 @@ export class GitHandler { const trackedPathSpecs: string[] = [] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - const { stdout } = await this.git(['ls-files', '-z', '--', ...chunk], worktreePath) + const { stdout } = await this.git( + ['ls-files', '-z', '--', ...chunk.map((p) => this.literalPathspec(p))], + worktreePath + ) trackedPathSpecs.push(...stdout.split('\0').filter(Boolean)) } @@ -282,18 +292,35 @@ export class GitHandler { async () => { for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git(['restore', '--worktree', '--source=HEAD', '--', ...chunk], worktreePath) + await this.git( + [ + 'restore', + '--worktree', + '--source=HEAD', + '--', + ...chunk.map((p) => this.literalPathspec(p)) + ], + worktreePath + ) } } ) } + private literalPathspec(filePath: string): string { + // Why: source-control selections are concrete paths, not user-authored Git globs. + return `:(literal)${filePath}` + } + private async cleanUntrackedPaths(worktreePath: string, filePaths: readonly string[]) { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await this.git(['clean', '-ffdx', '--', ...chunk], worktreePath) + await this.git( + ['clean', '-ffdx', '--', ...chunk.map((p) => this.literalPathspec(p))], + worktreePath + ) } } } @@ -460,26 +487,30 @@ export class GitHandler { } } - private async pull(params: Record) { + private async pullWithArgs(params: Record, pullArgs: string[]) { const worktreePath = params.worktreePath as string - // Why: plain `git pull` uses the user's configured pull strategy (merge by - // default) so diverged branches reconcile instead of erroring out. try { if (params.pushTarget !== undefined) { assertGitPushTargetShape(params.pushTarget) const pushTarget = params.pushTarget as GitPushTarget await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) - await this.git(['pull', pushTarget.remoteName, pushTarget.branchName], worktreePath) + await this.git( + ['pull', ...pullArgs, pushTarget.remoteName, pushTarget.branchName], + worktreePath + ) return } const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath)) if (upstream && !upstream.isConfiguredUpstream) { // Why: legacy Orca branches may still track origin/main while pushes // target origin/. Pull the same effective branch the UI reports. - await this.git(['pull', upstream.remoteName, upstream.branchName], worktreePath) + await this.git( + ['pull', ...pullArgs, upstream.remoteName, upstream.branchName], + worktreePath + ) return } - await this.git(['pull'], worktreePath) + await this.git(['pull', ...pullArgs], worktreePath) } catch (error) { // Why: mirror the local gitPull normalization so SSH users see the same // actionable messages instead of raw git stderr. @@ -487,6 +518,16 @@ export class GitHandler { } } + private async pull(params: Record) { + // Why: plain `git pull` uses the user's configured pull strategy (merge by + // default) so diverged branches reconcile instead of erroring out. + await this.pullWithArgs(params, []) + } + + private async fastForward(params: Record) { + await this.pullWithArgs(params, ['--ff-only']) + } + private async rebaseFromBase(params: Record) { const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d2a412df89f..f775133e582 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -115,7 +115,11 @@ import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types' import type { OnboardingState } from '../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { getFeatureTipsAppOpenDecision } from './components/feature-tips/feature-tip-startup-gate' +import { + getFeatureTipsAppOpenDecision, + isCliFeatureTipCompleted +} from './components/feature-tips/feature-tip-startup-gate' +import { trackOrcaCliFeatureTipShown } from './components/feature-tips/feature-tip-telemetry' import { keybindingMatchesAction, type KeybindingActionId, @@ -355,6 +359,17 @@ function App(): React.JSX.Element { // Why: the floating workspace is a transient overlay; hotkey minimize should // return keyboard focus to the surface the user was working in before it. const floatingTerminalReturnFocusRef = useRef(null) + const floatingTerminalReturnFocusFrameRef = useRef(null) + + const cancelFloatingTerminalReturnFocusFrame = useCallback((): void => { + if (floatingTerminalReturnFocusFrameRef.current === null) { + return + } + cancelAnimationFrame(floatingTerminalReturnFocusFrameRef.current) + floatingTerminalReturnFocusFrameRef.current = null + }, []) + + useEffect(() => cancelFloatingTerminalReturnFocusFrame, [cancelFloatingTerminalReturnFocusFrame]) const rememberFloatingTerminalReturnFocus = useCallback((): void => { const active = document.activeElement @@ -377,10 +392,15 @@ function App(): React.JSX.Element { if (!target || !document.contains(target)) { return } - requestAnimationFrame(() => { + cancelFloatingTerminalReturnFocusFrame() + floatingTerminalReturnFocusFrameRef.current = requestAnimationFrame(() => { + floatingTerminalReturnFocusFrameRef.current = null + if (!document.contains(target)) { + return + } target.focus({ preventScroll: true }) }) - }, []) + }, [cancelFloatingTerminalReturnFocusFrame]) const setFloatingTerminalOpenWithFocus = useCallback( (nextOpen: SetStateAction): void => { @@ -446,6 +466,7 @@ function App(): React.JSX.Element { const [onboarding, setOnboarding] = useState(null) const featureTipsPromptedThisSessionRef = useRef(false) const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) + const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false) const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding) const onboardingSettingsDetourActive = @@ -486,9 +507,35 @@ function App(): React.JSX.Element { return onOnboardingReopened(setOnboarding) }, []) + useEffect(() => { + if (!persistedUIReady) { + return + } + + let cancelled = false + void window.api.cli + .getInstallStatus() + .then((status) => { + if (cancelled) { + return + } + setFeatureTipCliInstalled(isCliFeatureTipCompleted(status)) + }) + .catch(() => { + if (!cancelled) { + setFeatureTipCliInstalled(true) + } + }) + + return () => { + cancelled = true + } + }, [persistedUIReady]) + useEffect(() => { const featureTipsDecision = getFeatureTipsAppOpenDecision({ activeModal, + cliInstalled: featureTipCliInstalled, featureTipsSeenIds, featureInteractions, onboarding, @@ -510,6 +557,9 @@ function App(): React.JSX.Element { } featureTipsPromptedThisSessionRef.current = true + if (featureTipsDecision.tipId === 'orca-cli') { + trackOrcaCliFeatureTipShown('app_open') + } // Why: once a tip is visible, app quit/crash should not make it reappear // on the next launch just because the user never clicked a dismiss button. actions.markFeatureTipsSeen([featureTipsDecision.tipId]) @@ -517,6 +567,7 @@ function App(): React.JSX.Element { }, [ activeModal, actions, + featureTipCliInstalled, featureInteractions, featureTipsSeenIds, onboarding, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index fc93e49c0e6..4df13c3060b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1407,6 +1407,81 @@ transform-origin: center; } +/* CLI feature tip — terminal cursor inside the agent-control command. */ +@keyframes cli-tip-caret { + 0%, + 45% { + opacity: 1; + } + 46%, + 100% { + opacity: 0; + } +} + +.animate-cli-tip-caret { + animation: cli-tip-caret 0.9s steps(1, end) infinite; +} + +@keyframes cli-tip-command-line { + 0% { + opacity: 0; + transform: translateY(2px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-cli-tip-command-line { + animation: cli-tip-command-line 160ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .animate-cli-tip-caret { + animation: none; + opacity: 1; + transform: none; + } + + .animate-cli-tip-command-line { + animation: none; + } +} + +.cli-tip-orchestration-frame .feature-wall-orch-stage { + --feature-wall-workspace-title-size: 11.5px; + --feature-wall-agent-message-size: 10.5px; + --feature-wall-agent-row-gap: 5px; + --feature-wall-agent-status-col: 10px; + --feature-wall-agent-icon-col: 12px; + --feature-wall-agent-status-box: 10px; + --feature-wall-agent-status-icon: 9px; + --feature-wall-agent-icon-box: 12px; + --feature-wall-child-indent: 22px; + align-content: start !important; + padding-top: 10px !important; + padding-left: 12px !important; + padding-right: 52px !important; +} + +.cli-tip-orchestration-frame .feature-wall-agent-icon svg { + height: 11px; + width: 11px; +} + +.cli-tip-orchestration-frame .feature-wall-agent-status [aria-label] { + height: 10px; + width: 10px; +} + +.cli-tip-orchestration-frame .feature-wall-agent-status [aria-label] > span { + height: 6px; + width: 6px; + border-width: 1.5px; +} + /* ── Feature wall: agents-orchestration animations ───────────────── */ /* Why: drives the supported-agents marquee on page 1 of the agents tile. @@ -1509,7 +1584,7 @@ offset-distance: 100%; transform: translate(-12px, -12px) scale(1); transition: - offset-distance 1500ms cubic-bezier(0.25, 0.6, 0.25, 1), + offset-distance 1600ms cubic-bezier(0.25, 0.6, 0.25, 1), transform 280ms ease, opacity 280ms ease; } @@ -1537,37 +1612,6 @@ animation: feature-wall-msg-pop 1100ms ease-out; } -@keyframes feature-wall-orch-spin { - to { - transform: rotate(360deg); - } -} -.feature-wall-spawn-spinner { - width: 11px; - height: 11px; - border: 1.5px solid rgb(245 158 11); - border-top-color: transparent; - border-radius: 999px; - animation: feature-wall-orch-spin 800ms linear infinite; -} - -/* "Creating workspaces…" placeholder shown before the child workspaces - reveal. Once the orchestrator finishes spawning, the wrapper collapses - and the real child cards take its place. */ -.feature-wall-creating-children { - overflow: hidden; - opacity: 1; - max-height: 60px; - transition: - opacity 220ms ease, - max-height 320ms cubic-bezier(0.4, 0, 0.2, 1); -} -.feature-wall-creating-children[data-hidden='true'] { - opacity: 0; - max-height: 0; - pointer-events: none; -} - /* Children container fades in once the orchestrator finishes creating. */ .feature-wall-children-wrapper { opacity: 0; @@ -1581,6 +1625,20 @@ transform: none; } +@keyframes feature-wall-child-card-in { + 0% { + opacity: 0; + transform: translateY(4px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} +.feature-wall-child-card-shell { + animation: feature-wall-child-card-in 260ms ease-out both; +} + /* Each child agent row stays collapsed until *its* dispatch bubble lands on it, so the child workspace visibly gains an agent as work arrives. */ .feature-wall-spawn-row { @@ -2327,194 +2385,218 @@ html.onboarding-tour-start-transition::view-transition-new(root) { overlay first and never reach our buttons. Lifting the whole card creates a stacking context that keeps every nested control clickable. */ z-index: 1; - border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent); - border-left: 3px solid color-mix(in srgb, var(--foreground) 55%, transparent); - border-radius: 6px; - background: color-mix(in srgb, var(--foreground) 5%, var(--editor-surface)); - padding: 6px 8px 6px 10px; - font-family: var(--font-sans, system-ui, sans-serif); - box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 15%, transparent); + display: flex; + gap: 12px; + border: 1px solid color-mix(in srgb, var(--foreground) 10%, var(--border)); + border-radius: 8px; + background: var(--background); + padding: 12px 14px; + font-family: var(--font-sans, system-ui, -apple-system, sans-serif); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.04), + 0 4px 12px rgba(0, 0, 0, 0.03); + transition: + border-color 150ms ease, + box-shadow 150ms ease, + background-color 150ms ease; } .dark .orca-diff-comment-card { - background: color-mix(in srgb, var(--foreground) 6%, var(--editor-surface)); + border-color: color-mix(in srgb, var(--foreground) 12%, var(--border)); + background: color-mix(in srgb, var(--foreground) 2%, var(--editor-surface)); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.2), + 0 4px 16px rgba(0, 0, 0, 0.15); } +/* Avatar Column */ +.orca-diff-comment-avatar-col { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Content Column */ +.orca-diff-comment-content-col { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Header Row */ .orca-diff-comment-header { display: flex; align-items: center; justify-content: space-between; - gap: 8px; - margin-bottom: 4px; -} - -.orca-diff-comment-meta { - font-size: 11px; - color: var(--foreground); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.orca-diff-comment-author-row { - display: flex; + gap: 12px; min-width: 0; +} + +/* Meta Group (Name, line, timestamp) */ +.orca-diff-comment-meta-group { + display: flex; align-items: center; + flex-wrap: wrap; gap: 6px; - margin: 0 0 6px; - color: var(--muted-foreground); - font-size: 11px; - line-height: 1.2; + min-width: 0; + font-size: 13px; + line-height: 1.4; + font-weight: 600; + color: var(--foreground); } -.orca-diff-comment-avatar { - width: 18px; - height: 18px; - flex: 0 0 auto; - border-radius: 50%; - border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent); - background: var(--muted); +/* Action button pill: segmented control like Notion's Resolve / ... */ +.orca-diff-comment-actions-pill { + display: inline-flex; + align-items: center; + border: 1px solid color-mix(in srgb, var(--foreground) 10%, var(--border)); + border-radius: 6px; + background: var(--background); + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); } -.orca-diff-comment-avatar-fallback { +.dark .orca-diff-comment-actions-pill { + border-color: color-mix(in srgb, var(--foreground) 14%, var(--border)); + background: color-mix(in srgb, var(--foreground) 4%, var(--editor-surface)); +} + +.orca-diff-comment-pill-btn { display: inline-flex; align-items: center; justify-content: center; + padding: 4px 8px; + font-size: 11px; + font-weight: 500; color: var(--muted-foreground); - font-size: 10px; + border: none; + background: transparent; + cursor: pointer; + height: 24px; + transition: + background-color 100ms ease, + color 100ms ease; +} + +.orca-diff-comment-pill-btn:hover { + background: color-mix(in srgb, var(--foreground) 6%, transparent); + color: var(--foreground); +} + +.orca-diff-comment-pill-btn-danger:hover { + background: color-mix(in srgb, var(--destructive) 10%, transparent); + color: var(--destructive); +} + +.orca-diff-comment-pill-divider { + width: 1px; + height: 12px; + background: color-mix(in srgb, var(--foreground) 12%, var(--border)); + flex-shrink: 0; +} + +/* Avatar design */ +.orca-diff-comment-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + object-fit: cover; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.orca-diff-comment-avatar-fallback, +.orca-diff-comment-avatar-local { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--primary) 8%, var(--muted)); + border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent); + color: var(--primary); + font-size: 11px; font-weight: 600; } -.orca-diff-comment-author { +.orca-diff-comment-avatar-local { + background: color-mix(in srgb, var(--foreground) 6%, var(--muted)); + border-color: color-mix(in srgb, var(--foreground) 12%, transparent); + color: var(--muted-foreground); +} + +/* Quote/Reference block: Warm, gorgeous Notion-like callout */ +.orca-diff-comment-quote { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 4px 0 6px 0; + padding-left: 8px; + border-left: 2px solid color-mix(in srgb, #e7b10a 35%, transparent); + color: var(--muted-foreground); + font-size: 12.5px; + line-height: 1.4; +} + +.orca-diff-comment-quote-text { + flex: 1; min-width: 0; overflow: hidden; - color: var(--foreground); - font-weight: 600; text-overflow: ellipsis; - white-space: nowrap; -} - -.orca-diff-comment-created-at { - flex: 0 0 auto; -} - -.orca-diff-comment-link { - margin-left: auto; - flex: 0 0 auto; - border: 0; - background: transparent; - color: var(--muted-foreground); - font: inherit; - cursor: pointer; -} - -.orca-diff-comment-link:hover { - color: var(--foreground); - text-decoration: underline; -} - -/* Why: keep edit + delete close together but visually distinct from the meta - label. Both share the same subtle button styling; only delete recolours on - hover (destructive intent). */ -.orca-diff-comment-actions { - display: inline-flex; - align-items: center; - gap: 2px; -} - -.orca-diff-comment-edit { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 3px; - border-radius: 4px; - border: 1px solid transparent; - background: transparent; - color: var(--muted-foreground); - cursor: pointer; - pointer-events: auto; - position: relative; - z-index: 1; -} - -.orca-diff-comment-edit:hover { - color: var(--foreground); - border-color: color-mix(in srgb, var(--foreground) 20%, transparent); - background: color-mix(in srgb, var(--foreground) 6%, transparent); -} - -/* Why: the trash button is always visible (not hover-only) because the saved - note is a persistent, action-bearing card — users should be able to see and - target the delete affordance without guessing that hovering reveals it. - Pointer-events are explicit since the card lives inside a Monaco view zone - where stray handlers higher in the tree have swallowed pointer events in - the past. z-index keeps it above the note body for click reliability. */ -.orca-diff-comment-delete { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 3px; - border-radius: 4px; - border: 1px solid transparent; - background: transparent; - color: var(--muted-foreground); - cursor: pointer; - pointer-events: auto; - position: relative; - z-index: 1; -} - -.orca-diff-comment-delete:hover { - color: var(--destructive); - border-color: color-mix(in srgb, var(--destructive) 40%, transparent); - background: color-mix(in srgb, var(--destructive) 10%, transparent); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + word-break: break-all; } +/* Comment Body */ .orca-diff-comment-body { - font-size: 12px; - line-height: 1.4; + font-size: 13.5px; + line-height: 1.5; color: var(--foreground); white-space: pre-wrap; word-break: break-word; + margin-top: 2px; } -.orca-diff-comment-review-body { - padding-left: 24px; -} - -/* Why: mirror the saved-note card treatment so the "entering" and "saved" - states read as the same object at two stages of the same flow. Same - foreground-tinted fill over --editor-surface, same border + left accent - bar, same shadow. Kept one level stronger on the shadow because the - popover floats above the editor instead of being laid into a view zone. */ +/* Popover for adding new comment */ .orca-diff-comment-popover { position: absolute; left: 56px; right: 24px; max-width: 420px; z-index: 1000; - padding: 8px 8px 8px 10px; - border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent); - border-left: 3px solid color-mix(in srgb, var(--foreground) 55%, transparent); - border-radius: 6px; - background: color-mix(in srgb, var(--foreground) 5%, var(--editor-surface)); + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--foreground) 10%, var(--border)); + border-radius: 8px; + background: var(--background); color: var(--foreground); - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); + box-shadow: + 0 10px 32px rgba(0, 0, 0, 0.12), + 0 4px 12px rgba(0, 0, 0, 0.08); display: flex; - flex-direction: column; - gap: 6px; + gap: 12px; } .dark .orca-diff-comment-popover { - background: color-mix(in srgb, var(--foreground) 6%, var(--editor-surface)); + border-color: color-mix(in srgb, var(--foreground) 14%, var(--border)); + background: color-mix(in srgb, var(--foreground) 2%, var(--editor-surface)); + box-shadow: + 0 10px 32px rgba(0, 0, 0, 0.3), + 0 4px 16px rgba(0, 0, 0, 0.2); } .orca-diff-comment-popover-label { - font-size: 10px; + font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted-foreground); + margin-bottom: 2px; } .orca-diff-comment-popover-textarea { @@ -2523,19 +2605,28 @@ html.onboarding-tour-start-transition::view-transition-new(root) { max-height: 240px; overflow-y: auto; resize: none; - padding: 6px 8px; - border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent); - border-radius: 4px; - /* Why: match the editor surface so the input reads as embedded in the card - rather than a pure-white/pure-black cutout pasted on top. */ - background: var(--editor-surface); + padding: 8px 12px; + border: 1px solid color-mix(in srgb, var(--foreground) 12%, var(--border)); + border-radius: 6px; + background: var(--background); color: var(--foreground); font-family: inherit; - font-size: 12px; - line-height: 1.4; + font-size: 13px; + line-height: 1.45; outline: none; - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 34%, transparent) transparent; + transition: + border-color 150ms ease, + box-shadow 150ms ease; +} + +.dark .orca-diff-comment-popover-textarea { + border-color: color-mix(in srgb, var(--foreground) 16%, var(--border)); + background: color-mix(in srgb, var(--foreground) 3%, var(--editor-surface)); +} + +.orca-diff-comment-popover-textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 15%, transparent); } .orca-diff-comment-popover-textarea::-webkit-scrollbar { @@ -2552,13 +2643,10 @@ html.onboarding-tour-start-transition::view-transition-new(root) { background-clip: padding-box; } -.orca-diff-comment-popover-textarea:focus { - border-color: color-mix(in srgb, var(--primary) 60%, var(--border)); -} - .orca-diff-comment-popover-footer { display: flex; align-items: center; justify-content: flex-end; - gap: 6px; + gap: 8px; + margin-top: 4px; } diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css index ac0ca68f29f..671d01893d8 100644 --- a/src/renderer/src/assets/markdown-preview.css +++ b/src/renderer/src/assets/markdown-preview.css @@ -211,8 +211,7 @@ } .markdown-review-toolbar { - position: sticky; - top: 0; + position: relative; z-index: 15; display: flex; width: fit-content; @@ -254,7 +253,7 @@ padding: 0; } -.markdown-review-toolbar-button:hover, +.markdown-review-toolbar-button:hover:not(:disabled), .markdown-review-toolbar-button[aria-expanded='true'], .markdown-review-icon-button:hover:not(:disabled) { border-color: color-mix(in srgb, var(--border) 82%, transparent); @@ -262,6 +261,11 @@ color: var(--foreground); } +.markdown-review-toolbar-button:disabled { + cursor: default; + opacity: 0.48; +} + .markdown-review-icon-button:disabled { cursor: default; opacity: 0.45; @@ -281,157 +285,49 @@ line-height: 1; } -.markdown-review-panel { - display: flex; - width: 310px; - min-width: 260px; - max-width: 36%; - flex-shrink: 0; - flex-direction: column; - border-left: 1px solid color-mix(in srgb, var(--border) 72%, transparent); - background: color-mix(in srgb, var(--background) 90%, var(--editor-surface)); -} - -.markdown-review-panel-header { - display: flex; - min-height: 40px; - align-items: center; - justify-content: space-between; - gap: 8px; - border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); - padding: 0 8px 0 12px; -} - -.markdown-review-panel-title, -.markdown-review-panel-actions { - display: inline-flex; - min-width: 0; - align-items: center; - gap: 6px; -} - -.markdown-review-panel-title { - color: var(--foreground); - font-size: 12px; - font-weight: 600; -} - -.markdown-review-panel-actions { - flex-shrink: 0; - gap: 2px; -} - -.markdown-review-note-list { - min-height: 0; - flex: 1; - overflow-y: auto; - padding: 8px; -} - -.markdown-review-empty { - padding: 8px 4px; - color: var(--muted-foreground); - font-size: 12px; - line-height: 1.45; -} - -.markdown-review-note { - border: 1px solid transparent; - border-radius: 7px; - padding: 6px; -} - -.markdown-review-note + .markdown-review-note { - margin-top: 8px; -} - -.markdown-review-note.is-active { - border-color: color-mix(in srgb, var(--foreground) 28%, transparent); - background: color-mix(in srgb, var(--foreground) 5%, transparent); -} - -.markdown-review-note-anchor { - display: flex; - width: 100%; - min-width: 0; - align-items: flex-start; - gap: 7px; - border: 0; - border-radius: 5px; - background: transparent; - padding: 4px 5px 6px; - color: inherit; - cursor: pointer; - text-align: left; -} - -.markdown-review-note-anchor:hover { - background: var(--accent); -} - -.markdown-review-note-line { - flex-shrink: 0; - color: var(--muted-foreground); - font-size: 11px; - font-variant-numeric: tabular-nums; - font-weight: 700; - line-height: 1.35; -} - -.markdown-review-note-excerpt { - min-width: 0; - display: -webkit-box; - overflow: hidden; - color: var(--muted-foreground); - font-size: 11px; - line-height: 1.35; - white-space: pre-wrap; - word-break: break-word; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; -} - -.markdown-review-note .orca-diff-comment-card { - max-width: none; -} - -@media (max-width: 900px) { - .markdown-review-panel { - width: 260px; - max-width: 48%; - } -} - -@container (max-width: 560px) { - .markdown-review-panel { - position: absolute; - top: 0; - right: 0; - bottom: 0; - z-index: 32; - width: min(310px, 82cqw); - min-width: 0; - max-width: calc(100cqw - 36px); - box-shadow: -10px 0 24px rgb(0 0 0 / 0.14); - } -} - -.markdown-annotation-block { +.markdown-annotation-block, +.markdown-annotation-list-block { position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, min(34cqw, 360px)); + column-gap: 32px; + align-items: start; +} + +.markdown-annotation-block > :not(.markdown-annotation-controls), +.markdown-annotation-list-content { + grid-column: 1; + min-width: 0; +} + +.markdown-annotation-block.has-review-notes > :not(.markdown-annotation-controls), +.markdown-annotation-list-block.has-review-notes > .markdown-annotation-list-content { + cursor: pointer; } .markdown-annotation-block:hover > .markdown-annotation-controls > .markdown-annotation-add, .markdown-annotation-block:focus-within > .markdown-annotation-controls > .markdown-annotation-add, -li:hover > .markdown-annotation-controls > .markdown-annotation-add, -li:focus-within > .markdown-annotation-controls > .markdown-annotation-add { +.markdown-annotation-list-block:hover > .markdown-annotation-controls > .markdown-annotation-add, +.markdown-annotation-list-block:focus-within + > .markdown-annotation-controls + > .markdown-annotation-add { opacity: 1; } .markdown-annotation-controls { - margin-top: 6px; + /* Why: note cards must share the document scroll with their source block; + laying the rail into each block avoids a separate right-side viewport. */ + position: relative; + grid-column: 2; + grid-row: 1; + min-width: 0; + margin-top: 0; } .markdown-annotation-add { + position: absolute; + top: 2px; + left: -26px; display: inline-flex; width: 20px; height: 20px; @@ -462,8 +358,20 @@ li:focus-within > .markdown-annotation-controls > .markdown-annotation-add { .markdown-annotation-composer, .markdown-annotation-card { - max-width: 520px; - margin-top: 6px; + max-width: none; + margin-top: 0; + scroll-margin: 12px; +} + +.markdown-annotation-note-stack { + display: flex; + min-width: 0; + flex-direction: column; + gap: 8px; +} + +.markdown-annotation-composer + .markdown-annotation-note-stack { + margin-top: 8px; } .markdown-annotation-card.is-active > .orca-diff-comment-card { @@ -471,6 +379,31 @@ li:focus-within > .markdown-annotation-controls > .markdown-annotation-add { background: color-mix(in srgb, var(--foreground) 8%, var(--editor-surface)); } +.markdown-annotation-card.is-attention > .orca-diff-comment-card { + animation: markdown-note-attention 900ms ease-out; +} + +@keyframes markdown-note-attention { + 0% { + transform: translateX(0); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ring) 55%, transparent), + 0 0 0 8px color-mix(in srgb, var(--ring) 16%, transparent); + } + + 35% { + transform: translateX(-3px); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ring) 45%, transparent), + 0 0 0 5px color-mix(in srgb, var(--ring) 12%, transparent); + } + + 100% { + transform: translateX(0); + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 15%, transparent); + } +} + .markdown-annotation-composer { border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent); border-radius: 6px; @@ -479,6 +412,26 @@ li:focus-within > .markdown-annotation-controls > .markdown-annotation-add { box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent); } +@container (max-width: 760px) { + .markdown-annotation-block, + .markdown-annotation-list-block { + display: block; + } + + .markdown-annotation-controls { + margin-top: 6px; + } + + .markdown-annotation-add { + position: static; + } + + .markdown-annotation-composer, + .markdown-annotation-note-stack { + margin-top: 6px; + } +} + .markdown-body h1, .markdown-body h2, .markdown-body h3, diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index a8605cda74d..23dd55cd508 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -73,6 +73,27 @@ -webkit-box-decoration-break: clone; } +.rich-markdown-annotation-selection-active { + animation: rich-markdown-source-attention 900ms ease-out; +} + +@keyframes rich-markdown-source-attention { + 0% { + background: color-mix(in srgb, var(--annotation-highlight) 44%, transparent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--annotation-highlight) 16%, transparent); + } + + 42% { + background: color-mix(in srgb, var(--annotation-highlight) 34%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--annotation-highlight) 12%, transparent); + } + + 100% { + background: color-mix(in srgb, var(--annotation-highlight) 22%, transparent); + box-shadow: none; + } +} + .rich-markdown-editor-shell.has-rich-markdown-review-notes .rich-markdown-editor { padding-right: min(360px, 34%); } @@ -129,7 +150,6 @@ position: absolute; top: 0; right: 16px; - bottom: 0; width: min(300px, 30%); pointer-events: none; z-index: 30; @@ -142,6 +162,36 @@ pointer-events: auto; } +.rich-markdown-review-note-card.is-active > .orca-diff-comment-card { + border-color: color-mix(in srgb, var(--foreground) 32%, transparent); + background: color-mix(in srgb, var(--foreground) 8%, var(--editor-surface)); +} + +.rich-markdown-review-note-card.is-attention > .orca-diff-comment-card { + animation: rich-markdown-note-attention 900ms ease-out; +} + +@keyframes rich-markdown-note-attention { + 0% { + transform: translateX(0); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ring) 55%, transparent), + 0 0 0 8px color-mix(in srgb, var(--ring) 16%, transparent); + } + + 35% { + transform: translateX(-3px); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ring) 45%, transparent), + 0 0 0 5px color-mix(in srgb, var(--ring) 12%, transparent); + } + + 100% { + transform: translateX(0); + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 15%, transparent); + } +} + .rich-markdown-review-note-send { display: inline-flex; align-items: center; diff --git a/src/renderer/src/components/FirstLaunchBanner.tsx b/src/renderer/src/components/FirstLaunchBanner.tsx index 8a03379786e..ba727f47282 100644 --- a/src/renderer/src/components/FirstLaunchBanner.tsx +++ b/src/renderer/src/components/FirstLaunchBanner.tsx @@ -38,6 +38,7 @@ import { X } from 'lucide-react' import { Button } from './ui/button' import { acknowledgeBanner, PRIVACY_URL, setOptIn as telemetrySetOptIn } from '../lib/telemetry' +import { useMountedRef } from '@/hooks/useMountedRef' type FirstLaunchBannerProps = { onResolve: () => void @@ -57,6 +58,7 @@ export function FirstLaunchBanner({ // wasted IPC round-trip, but the guard also blocks a Turn-off click // arriving mid-flight after an acknowledge (or vice versa). const [inFlight, setInFlight] = useState(false) + const mountedRef = useMountedRef() const handleAcknowledge = async (): Promise => { if (inFlight) { @@ -74,13 +76,17 @@ export function FirstLaunchBanner({ try { await acknowledgeBanner() await fetchSettings() - onResolve() + if (mountedRef.current) { + onResolve() + } } finally { // Why: if `fetchSettings` rejects (IPC error during shutdown, // settings file lock, etc.), `onResolve` never runs and the banner // stays mounted. Without resetting `inFlight`, every button stays // permanently disabled for the rest of the session. - setInFlight(false) + if (mountedRef.current) { + setInFlight(false) + } } } @@ -97,9 +103,13 @@ export function FirstLaunchBanner({ try { await telemetrySetOptIn(false) await fetchSettings() - onResolve() + if (mountedRef.current) { + onResolve() + } } finally { - setInFlight(false) + if (mountedRef.current) { + setInFlight(false) + } } } diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 0df1667fd69..c6975b45ee0 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -61,12 +61,14 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { detectLanguage } from '@/lib/language-detect' import { cn } from '@/lib/utils' import { DiffSectionItem } from '@/components/editor/DiffSectionItem' +import { useMountedRef } from '@/hooks/useMountedRef' import type { DecoratedDiffComment } from '@/components/diff-comments/useDiffCommentDecorator' import { CombinedDiffFileTree, @@ -107,6 +109,7 @@ import { getGitHubPRReviewerRows, normalizeGitHubReviewerLogins } from '@/components/github-pr-reviewer-display' +import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import { getConnectionId } from '@/lib/connection-context' @@ -365,31 +368,6 @@ function getStateTone(item: GitHubWorkItem): string { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } -function getPRMergeTooltip(item: GitHubWorkItem): string { - if (item.state === 'merged') { - return 'This pull request is already merged' - } - if (item.state === 'closed') { - return 'This pull request is closed' - } - if (item.mergeable === undefined && item.mergeStateStatus === undefined) { - return 'Merge status is unavailable for this PR' - } - if (item.mergeable === 'CONFLICTING') { - return 'GitHub reports merge conflicts' - } - if (item.mergeStateStatus === 'BEHIND') { - return 'Update the branch before merging' - } - if (item.mergeStateStatus === 'BLOCKED') { - return 'GitHub reports this pull request is blocked' - } - if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { - return 'GitHub says this PR can merge' - } - return 'GitHub has not reported a final merge status' -} - function WorkItemStateBadge({ item, className @@ -501,6 +479,34 @@ function PRReviewersPanel({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const settings = useAppStore((s) => s.settings) const reviewerInputRef = useRef(null) + const reviewerInputFocusFrameRef = useRef(null) + const reviewerPanelMountedRef = useRef(true) + + const cancelReviewerInputFocusFrame = useCallback((): void => { + if (reviewerInputFocusFrameRef.current !== null) { + cancelAnimationFrame(reviewerInputFocusFrameRef.current) + reviewerInputFocusFrameRef.current = null + } + }, []) + + const scheduleReviewerInputFocus = useCallback((): void => { + if (!reviewerPanelMountedRef.current) { + return + } + cancelReviewerInputFocusFrame() + reviewerInputFocusFrameRef.current = requestAnimationFrame(() => { + reviewerInputFocusFrameRef.current = null + reviewerInputRef.current?.focus() + }) + }, [cancelReviewerInputFocusFrame]) + + useEffect(() => { + reviewerPanelMountedRef.current = true + return () => { + reviewerPanelMountedRef.current = false + cancelReviewerInputFocusFrame() + } + }, [cancelReviewerInputFocusFrame]) useEffect(() => { setLocalReviewRequests(item.reviewRequests ?? []) @@ -759,7 +765,7 @@ function PRReviewersPanel({ await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) ? handleRemoveReviewers([reviewer.login]) : handleRequestReview([reviewer.login])) - requestAnimationFrame(() => reviewerInputRef.current?.focus()) + scheduleReviewerInputFocus() } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { @@ -768,7 +774,7 @@ function PRReviewersPanel({ } setOpen(nextOpen) if (nextOpen) { - requestAnimationFrame(() => reviewerInputRef.current?.focus()) + scheduleReviewerInputFocus() return } setReviewerInput('') @@ -2341,6 +2347,7 @@ function ConversationTab({ const [bodyEditing, setBodyEditing] = useState(false) const [bodySaving, setBodySaving] = useState(false) const bodyTextareaRef = useRef(null) + const bodyTextareaFocusFrameRef = useRef(null) const repoAssignees = useRepoAssignees(repoPath, item.repoId) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( @@ -2359,6 +2366,13 @@ function ConversationTab({ [comments, detailsParticipants, item, repoAssignees.data] ) + const cancelBodyTextareaFocusFrame = useCallback((): void => { + if (bodyTextareaFocusFrameRef.current !== null) { + cancelAnimationFrame(bodyTextareaFocusFrameRef.current) + bodyTextareaFocusFrameRef.current = null + } + }, []) + useEffect(() => { if (replyingTo !== null && !visibleComments.some((comment) => comment.id === replyingTo)) { setReplyingTo(null) @@ -2372,10 +2386,17 @@ function ConversationTab({ }, [body, bodyEditing, item.id]) useEffect(() => { - if (bodyEditing) { - requestAnimationFrame(() => bodyTextareaRef.current?.focus()) + if (!bodyEditing) { + cancelBodyTextareaFocusFrame() + return cancelBodyTextareaFocusFrame } - }, [bodyEditing]) + cancelBodyTextareaFocusFrame() + bodyTextareaFocusFrameRef.current = requestAnimationFrame(() => { + bodyTextareaFocusFrameRef.current = null + bodyTextareaRef.current?.focus() + }) + return cancelBodyTextareaFocusFrame + }, [bodyEditing, cancelBodyTextareaFocusFrame]) const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) const markdownGitHubRepo = useMemo( @@ -2830,14 +2851,10 @@ function PRActionsPanel({ const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const confirm = useConfirmationDialog() const actionItem = { ...item, state: localState } + const mergePresentation = presentGitHubPRMergeState(actionItem) const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin) const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' - const mergeDisabled = - !repoPath || - mergePending || - localState === 'closed' || - localState === 'merged' || - item.mergeable === 'CONFLICTING' + const mergeDisabled = !repoPath || mergePending || !mergePresentation.directMergeAvailable const patchProjectRowIfNeeded = useCallback( (state: GitHubWorkItem['state']) => { @@ -2916,7 +2933,8 @@ function PRActionsPanel({ repoPath, repoId: repoId ?? undefined, prNumber: item.number, - method + method, + prRepo: item.prRepo ?? null }) if (!result.ok) { toast.error(result.error) @@ -2932,6 +2950,33 @@ function PRActionsPanel({ } } + const handleAutoMerge = async (): Promise => { + if (!repoPath || !mergePresentation.autoMergeAction) { + return + } + const enabled = mergePresentation.autoMergeAction.kind === 'enable' + setMergePending(true) + try { + const result = await window.api.gh.setPRAutoMerge({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + enabled, + prRepo: item.prRepo ?? null + }) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success(enabled ? 'Auto-merge enabled' : 'Auto-merge disabled') + onMutated() + } catch { + toast.error(enabled ? 'Failed to enable auto-merge' : 'Failed to disable auto-merge') + } finally { + setMergePending(false) + } + } + return (