Merge main into rich markdown menu selection PR

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-30 03:54:09 -07:00
co-authored by Orca
540 changed files with 32130 additions and 6657 deletions
+49 -20
View File
@@ -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<digit>...` 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`
+16 -2
View File
@@ -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 }}
+3
View File
@@ -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/*
+2 -1
View File
@@ -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"
+50
View File
@@ -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
+19
View File
@@ -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.
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>The AI Orchestrator for 100x builders.</strong><br/>
Run Claude Code, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Run Claude Code, OpenClaude, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Available for <strong>macOS, Windows, and Linux</strong>.
</p>
@@ -30,6 +30,7 @@ Orca supports any CLI agent (*not just this list*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -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.
+8 -3
View File
@@ -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
@@ -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}'
})
})
})
@@ -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)
@@ -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`
@@ -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`
@@ -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`,
@@ -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', () => {
+2
View File
@@ -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"
],
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
Ejecuta Claude Code, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
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.<br/>
Disponible para <strong>macOS, Windows y Linux</strong>.
</p>
@@ -32,6 +32,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -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.
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
Claude Code、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。<br/>
Claude Code、OpenClaude、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。<br/>
<strong>macOS、Windows、Linux</strong> で利用できます。
</p>
@@ -32,6 +32,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -64,7 +65,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス
## 機能
- **ログイン不要** — お持ちの Claude Code、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
<strong>macOS, Windows, Linux</strong>에서 사용할 수 있습니다.
</p>
@@ -32,6 +32,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -64,7 +65,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정
## 기능
- **로그인 불필요** — 보유한 Claude Code, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다.
- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다.
- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다.
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
跨仓库并排运行 Claude Code、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。<br/>
跨仓库并排运行 Claude Code、OpenClaude、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。<br/>
支持 <strong>macOS、Windows 和 Linux</strong>。
</p>
@@ -32,6 +32,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -64,7 +65,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
## 特性
- **无需登录** — 直接使用你自己的 Claude Code、Codex、Grok 或 Antigravity 订阅。
- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。
- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
@@ -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`
-355
View File
@@ -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.
+108
View File
@@ -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.
+2 -2
View File
@@ -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": {
+8 -22
View File
@@ -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#<base64>`, 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<Set<string>>(new Set())
// Why: route `orca://pair#<code>` 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() {
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
+9 -1
View File
@@ -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)
+211 -42
View File
@@ -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<MobileSessionTab, { type: 'terminal' }>
}
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<ConnectionState, string> = {
@@ -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<MobileNewTabAgentLoadState>('idle')
const [createTabAgentOptions, setCreateTabAgentOptions] = useState<MobileNewTabAgentOption[]>([])
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [markdownActionTarget, setMarkdownActionTarget] = useState<Extract<
@@ -724,8 +756,15 @@ export default function SessionScreen() {
const [leaveDrafts, setLeaveDrafts] = useState<DirtyMarkdownDraft[] | null>(null)
const [renameTarget, setRenameTarget] = useState<Terminal | null>(null)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
)
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [deleteKeyTarget, setDeleteKeyTarget] = useState<CustomKey | null>(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 (
<View style={styles.container}>
@@ -3279,15 +3460,16 @@ export default function SessionScreen() {
: 'Switch to live terminal input'
}
>
<Text
style={[
styles.accessoryKeyText,
liveInputEnabled && styles.accessoryKeyTextActive,
!canSend && styles.accessoryKeyTextDisabled
]}
>
Live
</Text>
<ChevronsRight
size={14}
color={
liveInputEnabled
? colors.bgBase
: canSend
? colors.textSecondary
: colors.textMuted
}
/>
</Pressable>
{canPaste && (
<Pressable
@@ -3307,9 +3489,9 @@ export default function SessionScreen() {
</Text>
</Pressable>
)}
{TERMINAL_ACCESSORY_KEYS.map((key) => (
{visibleBuiltInAccessoryKeys.map((key) => (
<Pressable
key={key.label}
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
@@ -3383,12 +3565,9 @@ export default function SessionScreen() {
onPress={focusLiveInput}
accessibilityLabel="Focus live terminal input"
>
<View style={styles.liveInputBadge}>
<KeyboardIcon size={13} color={colors.textPrimary} strokeWidth={2.2} />
<Text style={styles.liveInputBadgeText}>Live</Text>
</View>
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<Text style={styles.liveInputHint} numberOfLines={1}>
Keyboard input goes to terminal
Keyboard input directly goes to terminal
</Text>
<TextInput
ref={liveInputRef}
@@ -3512,7 +3691,8 @@ export default function SessionScreen() {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
}
},
...createTabAgentActions
]}
onClose={() => setShowCreateTabDrawer(false)}
/>
@@ -3775,6 +3955,7 @@ export default function SessionScreen() {
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={setCustomKeys}
onManageShortcuts={handleManageShortcuts}
/>
<ActionSheetModal
visible={deleteKeyTarget != null}
@@ -4194,7 +4375,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.borderSubtle
},
accessoryKeyActive: {
backgroundColor: colors.accentBlue
backgroundColor: colors.textPrimary
},
customAccessoryKey: {
borderWidth: 1,
@@ -4209,7 +4390,7 @@ const styles = StyleSheet.create({
fontFamily: typography.monoFamily
},
accessoryKeyTextActive: {
color: colors.textPrimary,
color: colors.bgBase,
fontWeight: '700'
},
accessoryKeyTextDisabled: {
@@ -4218,6 +4399,7 @@ const styles = StyleSheet.create({
inputBar: {
flexDirection: 'row',
alignItems: 'center',
minHeight: 46,
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
borderTopWidth: 1,
@@ -4226,11 +4408,12 @@ const styles = StyleSheet.create({
},
textInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
paddingVertical: 0,
fontSize: 14,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
@@ -4238,21 +4421,7 @@ const styles = StyleSheet.create({
liveInputBar: {
gap: spacing.sm
},
liveInputBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accentBlue,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radii.button
},
liveInputBadgeText: {
color: colors.textPrimary,
fontSize: 12,
fontWeight: '700',
fontFamily: typography.monoFamily
},
liveInputHint: {
flex: 1,
color: colors.textSecondary,
+4 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import {
ActivityIndicator,
@@ -2534,7 +2534,9 @@ export default function MobileTasksScreen() {
return [...logins].sort().join(',')
}, [projectRowDetail, projectRowItem?.content.assignees])
useEffect(() => {
// 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])
+16 -2
View File
@@ -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<HostProfile[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
@@ -676,7 +680,13 @@ export default function HomeScreen() {
{hosts.length === 0 ? (
/* ─── Empty state: onboarding ─── */
<View style={[styles.emptyContainer, { paddingBottom: insets.bottom }]}>
<View
style={[
styles.emptyContainer,
{ paddingBottom: insets.bottom },
isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
]}
>
<View style={styles.emptyHero}>
<Text style={styles.emptyTitle}>Connect your desktop</Text>
<Text style={styles.emptyBody}>
@@ -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={
<View>
<View style={styles.hero}>
+17 -7
View File
@@ -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<ConnectionLogEntry[]>([])
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 (
+2 -2
View File
@@ -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)}
/>
+87
View File
@@ -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 (
<View style={styles.container}>
{missingCode ? (
<>
<Text style={styles.errorText}>Missing pairing code</Text>
<Pressable style={styles.primaryButton} onPress={goHome}>
<Text style={styles.primaryButtonText}>Back to home</Text>
</Pressable>
</>
) : (
<ActivityIndicator size="large" color={colors.textSecondary} />
)}
</View>
)
}
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'
}
})
+246 -6
View File
@@ -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 (
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
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<CustomKey[]>([])
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<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
)
const layoutWriteChainRef = useRef<Promise<void>>(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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -186,6 +316,76 @@ export default function TerminalSettingsScreen() {
})}
</View>
)}
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => (
<View key={shortcutKey.id}>
{idx > 0 && <View style={styles.separator} />}
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
</View>
))}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>Show every built-in shortcut key</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
) : (
customKeys.map((key, idx) => (
<View key={key.id}>
{idx > 0 && <View style={styles.separator} />}
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
</View>
))
)}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</ScrollView>
<PickerModal<RestoreValue>
@@ -198,6 +398,14 @@ export default function TerminalSettingsScreen() {
}}
onClose={() => setPickerHostId(null)}
/>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
setCustomKeys(keys)
}}
/>
</View>
)
}
@@ -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)'
}
})
+1330 -955
View File
File diff suppressed because it is too large Load Diff
+1843 -1350
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -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'
@@ -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])')
})
})
+11 -1
View File
@@ -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({
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={styles.anchor} pointerEvents="box-none">
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
@@ -340,6 +347,9 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'flex-end'
},
anchorWide: {
alignItems: 'center'
},
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
+14 -1
View File
@@ -61,6 +61,7 @@ type Props = {
visible: boolean
onClose: () => void
onKeysChanged: (keys: CustomKey[]) => void
onManageShortcuts?: () => void
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
@@ -76,7 +77,7 @@ export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
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<Step>('choose-type')
const [shortcutKey, setShortcutKey] = useState('c')
const [shortcutModifiers, setShortcutModifiers] = useState<TerminalShortcutModifier[]>(['ctrl'])
@@ -216,6 +217,18 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
<Text style={styles.rowLabel}>Text Macro</Text>
<Text style={styles.rowHint}>Send custom text command</Text>
</Pressable>
{onManageShortcuts ? (
<>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onManageShortcuts}
>
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
<Text style={styles.rowHint}>Show or hide default shortcut keys</Text>
</Pressable>
</>
) : null}
</View>
)}
@@ -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
})
})
})
@@ -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
}
}
+12
View File
@@ -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)
}
@@ -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([])
})
})
@@ -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<unknown>,
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<unknown> | null
): MobileNewTabAgentOption[] {
if (!detectedAgentIds) {
return []
}
return orderMobileNewTabAgents(
settings?.defaultTuiAgent,
detectedAgentIds,
settings?.disabledTuiAgents
).map((agent) => ({
agent,
label: MOBILE_TUI_AGENT_LABELS[agent]
}))
}
+79 -8
View File
@@ -882,6 +882,8 @@ const XTERM_HTML = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE html>
stopEdgeScroll();
return;
}
syncEdgeScrollSelectionEndpoint();
repositionOverlay();
}, EDGE_SCROLL_INTERVAL);
}
@@ -1397,11 +1465,9 @@ const XTERM_HTML = `<!DOCTYPE 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 = `<!DOCTYPE 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) {
@@ -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',
+44 -18
View File
@@ -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(
@@ -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'
])
})
})
@@ -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>): string[] {
const seen = new Set<string>()
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<TerminalAccessoryLayoutPreference> {
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<void> {
const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
}
@@ -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)
})
})
+28 -8
View File
@@ -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
@@ -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')
)
})
})
+21
View File
@@ -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()
})
})
+25 -4
View File
@@ -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#<base64>` 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 {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+19 -12
View File
@@ -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 <subcommand>`, Linux users should substitute `orca-ide <subcommand>`. 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
+2 -2
View File
@@ -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 <task_id> --to <handle> [--from <handle>] [--
orca orchestration dispatch-show --task <task_id> [--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`.
+1 -1
View File
@@ -209,7 +209,7 @@ Wait Options:
Output Options:
--json Emit machine-readable JSON instead of human text
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair#... code
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code
--environment <selector> Connect using a saved environment id or name
--help Show this help message
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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)
})
})
+4 -1
View File
@@ -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()
+1 -1
View File
@@ -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 <name> --pairing-code <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'],
@@ -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'
}
}
@@ -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()],
@@ -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<string, string>
@@ -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<string, { command?: string; hooks?: unknown[] }[]>
@@ -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'
)
})
})
@@ -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)],
+384
View File
@@ -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
+137
View File
@@ -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<string, unknown>
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')
})
})
+367
View File
@@ -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<string, unknown> = {}',
' 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<string, unknown>): Promise<void> {',
' 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<string, unknown>): 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<AgentHookInstallStatus> {
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
}
@@ -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<string, unknown>
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<string, unknown>).message
if (typeof message === 'string') {
return message
}
}
return 'Selection failed'
}
export class BrowserGrabSessionController {
private readonly activeGrabOps = new Map<string, ActiveGrabOp>()
@@ -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 {
@@ -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: '<button>Click me</button>',
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'))
@@ -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', () => {
+4 -2
View File
@@ -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 });
};
})`
@@ -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')
+204 -20
View File
@@ -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<unknown> = Promise.resolve()
@@ -83,18 +101,22 @@ export class ClaudeRuntimeAuthService {
void this.safeSyncForCurrentSelection()
}
async prepareForClaudeLaunch(): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection()
return this.getPreparation()
async prepareForClaudeLaunch(
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection(target)
return this.getPreparation(target)
}
async prepareForRateLimitFetch(): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection()
return this.getPreparation()
async prepareForRateLimitFetch(
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection(target)
return this.getPreparation(target)
}
async syncForCurrentSelection(): Promise<void> {
await this.serializeMutation(() => this.doSyncForCurrentSelection())
async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise<void> {
await this.serializeMutation(() => this.doSyncForCurrentSelection(target))
}
async forceMaterializeCurrentSelectionForRollback(): Promise<void> {
@@ -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<void> {
@@ -139,12 +161,12 @@ export class ClaudeRuntimeAuthService {
return next
}
private async doSyncForCurrentSelection(): Promise<void> {
private async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise<void> {
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<ClaudeReadBackMatch> {
@@ -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
})
@@ -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<GlobalSettings, 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime'>
> = {}
): Pick<GlobalSettings, 'activeClaudeManagedAccountId' | 'activeClaudeManagedAccountIdsByRuntime'> {
return {
activeClaudeManagedAccountId: null,
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} },
...overrides
}
}
function createAccount(
overrides: Partial<ClaudeManagedAccount> & Pick<ClaudeManagedAccount, 'id'>
): 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 }
})
})
})
@@ -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<string, string | null> = {}
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<string, string | null> = {}
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
}
+226 -2
View File
@@ -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<typeof settings>) => {
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<typeof settings>) => {
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'
})
})
})
+380 -56
View File
@@ -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<unknown> = Promise.resolve()
@@ -66,8 +95,8 @@ export class ClaudeAccountService {
return this.getSnapshot()
}
async addAccount(): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount())
async addAccount(target?: ClaudeAccountAddTarget): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount(target))
}
async reauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
@@ -82,19 +111,29 @@ export class ClaudeAccountService {
return this.serializeMutation(() => this.doSelectAccount(accountId))
}
async selectAccountForTarget(
accountId: string | null,
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doSelectAccount(accountId, target))
}
private serializeMutation<T>(fn: () => Promise<T>): Promise<T> {
const next = this.mutationQueue.then(fn, fn)
this.mutationQueue = next.catch(() => {})
return next
}
private async doAddAccount(): Promise<ClaudeRateLimitAccountsState> {
private async doAddAccount(
target?: ClaudeAccountAddTarget
): Promise<ClaudeRateLimitAccountsState> {
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<ClaudeRateLimitAccountsState> {
private async doSelectAccount(
accountId: string | null,
target?: ClaudeAccountSelectionTarget
): Promise<ClaudeRateLimitAccountsState> {
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<Store['getSettings']>): void {
this.store.updateSettings({
claudeManagedAccounts: settings.claudeManagedAccounts,
activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId
activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId,
activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime
})
}
private async syncRuntimeAuthWithLivePtyGate(operation?: () => Promise<void>): Promise<void> {
private async syncRuntimeAuthWithLivePtyGate(
target?: ClaudeAccountSelectionTarget,
operation?: () => Promise<void>
): Promise<void> {
beginClaudeAuthSwitch()
try {
await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection())
await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target))
} finally {
endClaudeAuthSwitch()
}
}
private async runClaudeLoginAndCapture(): Promise<CapturedClaudeAuth> {
const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-'))
private async runClaudeLoginAndCapture(
location: ManagedClaudeAuthLocation = {
managedAuthPath: '',
managedAuthRuntime: 'host',
wslDistro: null,
wslLinuxAuthPath: null
}
): Promise<CapturedClaudeAuth> {
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<string> {
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
+59 -1
View File
@@ -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<string, string>
@@ -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')
})
})
+52 -22
View File
@@ -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)
}
+32 -8
View File
@@ -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 {
+105 -64
View File
@@ -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
})
}
)
})
+52 -6
View File
@@ -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<string | null>
private readonly userPathWriter: (value: string) => Promise<void>
// 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<void> {
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<void> {
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')
}
+9 -4
View File
@@ -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)
+14 -2
View File
@@ -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<string, string>()
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 () => {
+16 -6
View File
@@ -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
}
+3 -1
View File
@@ -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 {
@@ -74,6 +74,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): 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()
})
+308 -19
View File
@@ -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<string, string | null>()
private readonly lastSyncedWslAccountIdByDistro = new Map<string, string | null>()
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
@@ -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<GlobalSettings, 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime'>
> = {}
): Pick<GlobalSettings, 'activeCodexManagedAccountId' | 'activeCodexManagedAccountIdsByRuntime'> {
return {
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} },
...overrides
}
}
function createAccount(
overrides: Partial<CodexManagedAccount> & Pick<CodexManagedAccount, 'id'>
): 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 }
})
})
})
@@ -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<string, string | null> = {}
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<string, string | null> = {}
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
}
+390
View File
@@ -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> = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true
@@ -61,6 +66,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): 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,
+356 -54
View File
@@ -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<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount())
async addAccount(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount(target))
}
async reauthenticateAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
@@ -75,9 +104,17 @@ export class CodexAccountService {
return this.serializeMutation(() => this.doSelectAccount(accountId))
}
private async doAddAccount(): Promise<CodexRateLimitAccountsState> {
async selectAccountForTarget(
accountId: string | null,
target?: CodexAccountSelectionTarget
): Promise<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doSelectAccount(accountId, target))
}
private async doAddAccount(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
private async doSelectAccount(
accountId: string | null,
target?: CodexAccountSelectionTarget
): Promise<CodexRateLimitAccountsState> {
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 <accounts-root>/<uuid>/home. Removing
// just the home/ leaf leaves an empty <uuid>/ directory behind.
try {
@@ -405,22 +684,45 @@ export class CodexAccountService {
private async runCodexLogin(managedHomePath: string): Promise<void> {
await new Promise<void>((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.'
@@ -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', () => {
@@ -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
}
+1
View File
@@ -17,6 +17,7 @@ import type { SubprocessHandle } from './session'
function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: () => null,
write() {},
resize() {},
kill() {},
+1
View File
@@ -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)),
@@ -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)),

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