diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6b24738879d..9d2eddc7582 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -80,7 +80,7 @@ All releases are cut from the **Cut Release** GitHub Actions workflow. There is - **ref**: the branch, tag, or SHA to build from. Defaults to `main`. 3. Run it. -The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and kicks off the multi-platform build via `release.yml`. +The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and runs the multi-platform build + publish inline. **How the next version is chosen:** diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml new file mode 100644 index 00000000000..6d68765f903 --- /dev/null +++ b/.github/workflows/computer-e2e.yml @@ -0,0 +1,104 @@ +name: Computer-use e2e + +on: + pull_request: + paths: + - '.github/workflows/computer-e2e.yml' + - 'config/electron-builder.config.cjs' + - 'config/scripts/build-computer-macos.mjs' + - 'config/scripts/verify-computer-native.mjs' + - 'native/computer-use-macos/**' + - 'native/computer-use-linux/**' + - 'native/computer-use-windows/**' + - 'src/cli/**' + - 'src/main/computer/**' + - 'src/main/runtime/rpc/methods/computer.ts' + - 'src/shared/runtime-types.ts' + - 'tests/e2e/computer-linux.e2e.ts' + - 'tests/e2e/computer-mac.e2e.ts' + - 'tests/e2e/computer-windows.e2e.ts' + - 'tests/e2e/helpers/computer-driver.ts' + workflow_dispatch: + schedule: + - cron: '0 6 * * *' + +jobs: + native-smoke: + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + - uses: pnpm/action-setup@v4 + with: + run_install: false + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool + - run: pnpm install --frozen-lockfile + - run: pnpm verify:computer-native + - run: pnpm build:cli + + mac: + # macOS Accessibility and Screen Recording require user-granted TCC entries. + # Keep this on manual/scheduled permission-bearing runners instead of PR CI. + if: github.event_name != 'pull_request' + runs-on: macos-14 + env: + ORCA_COMPUTER_E2E: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + - uses: pnpm/action-setup@v4 + with: + run_install: false + - run: pnpm install --frozen-lockfile + - run: pnpm build:computer-macos + - run: pnpm verify:computer-native + - run: pnpm build:cli + - run: pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-mac.e2e.ts + + linux: + if: github.event_name != 'pull_request' + runs-on: ubuntu-22.04 + env: + ORCA_COMPUTER_E2E: '1' + ACCESSIBILITY_ENABLED: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + - uses: pnpm/action-setup@v4 + with: + run_install: false + - run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool + - run: pnpm install --frozen-lockfile + - run: pnpm verify:computer-native + - run: pnpm build:cli + - run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-linux.e2e.ts + + windows: + if: github.event_name != 'pull_request' + runs-on: windows-latest + env: + ORCA_COMPUTER_E2E: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + - uses: pnpm/action-setup@v4 + with: + run_install: false + - run: pnpm install --frozen-lockfile + - run: pnpm verify:computer-native + - run: pnpm build:cli + - run: pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-windows.e2e.ts diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 9f1f373412f..28dac408130 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -2,15 +2,21 @@ name: Homebrew Cask Bump # Why: keep the stablyai/homebrew-orca tap in sync with GitHub Releases. # Triggers: -# - release.published with !prerelease → release workflow just finalized a -# stable tag (see publish-release step in release.yml) -# - workflow_call → release.yml finished publishing a stable tag +# - workflow_call → release-cut.yml finished publishing a stable tag # - 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. +# +# Why no `release.published` trigger: other products shipped from this repo +# (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket +# `release.published` trigger would fire this workflow for any of them and +# attempt to download `orca-macos-*.dmg` from a non-desktop release, at +# best 404ing and at worst (if the filter were weaker) rewriting the cask +# to a non-desktop version. The tap is only ever bumped by the desktop +# release pipeline calling this workflow directly. on: workflow_call: @@ -19,8 +25,6 @@ on: description: Release tag to sync (e.g. v1.3.25) required: true type: string - release: - types: [published] workflow_dispatch: inputs: tag: @@ -31,12 +35,15 @@ on: jobs: bump-cask: runs-on: ubuntu-latest - # Why: skip prereleases and RC tags. The stable cask tracks GA only; a - # beta channel would be a separate cask in a separate job. + # Why: defense in depth. The desktop release pipeline already refuses + # to invoke this workflow for `-rc.*` tags, but a manual backfill could + # still pass one in — reject it here so the stable cask can never point + # at an RC. Also requires the desktop tag shape `v...` so a + # mis-typed mobile tag like `mobile-v0.0.1` is rejected up front. if: > - inputs.tag != '' || - (github.event.release.prerelease == false && - !contains(github.event.release.tag_name, '-rc.')) + inputs.tag != '' && + !contains(inputs.tag, '-rc.') && + startsWith(inputs.tag, 'v') permissions: contents: read steps: diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 00000000000..9e7d88666aa --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,71 @@ +name: Mobile Release + +on: + push: + tags: + - 'mobile-v*' + workflow_dispatch: + +jobs: + android-build: + runs-on: ubuntu-latest + + # Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN + # to call `gh release create`, which requires contents:write. Without this, + # the job builds the APK fine but fails at release time with + # "HTTP 403: Resource not accessible by integration". + permissions: + contents: write + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Expo prebuild + run: npx expo prebuild --platform android --no-install + + - name: Build Android release APK + run: cd android && ./gradlew assembleRelease + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: orca-mobile-apk + path: mobile/android/app/build/outputs/apk/release/*.apk + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/mobile-v') + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF#refs/tags/}" + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Orca Mobile $tag" \ + --prerelease \ + --latest=false \ + --generate-notes \ + android/app/build/outputs/apk/release/*.apk diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 00000000000..95fcff00a08 --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,43 @@ +name: Mobile Checks + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - 'mobile/**' + +jobs: + verify: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: npx tsc --noEmit + + - name: Lint + run: pnpm lint + diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a477e80e887..e062db993f2 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -183,20 +183,28 @@ jobs: run: | set -euo pipefail - # Latest stable release tag, picked by *tag shape* (no `-rc.`) - # rather than the GitHub `isPrerelease` flag. electron-builder's - # publish step has flipped that flag back to `false` on RC - # releases in the past (issue surfaced 2026-04-27 when - # v1.3.22-rc.2 became "latest" on GitHub and poisoned the - # compute-next-version math here). Filtering by tag format is - # the authoritative signal — an `-rc.N` tag is never stable, - # regardless of what the release metadata says. + # Latest stable release tag, picked by *tag shape*: + # - must start with `v` (desktop convention, e.g. v1.3.32) + # - must NOT contain `-rc.` (not a prerelease) + # + # Why not the GitHub `isPrerelease` flag: electron-builder's publish + # step has flipped that flag back to `false` on RC releases before + # (v1.3.22-rc.2 on 2026-04-27 briefly became "latest" on GitHub and + # poisoned the math here). Tag format is authoritative. + # + # Why the `^v[0-9]` prefix: other products shipped from this repo + # use their own prefixes (e.g. `mobile-v0.0.1`). Without the prefix + # gate the latest mobile release would be selected as "latest + # stable", then `strip_pre()` would reduce `mobile-v0.0.1` to + # `mobile`, `Number("mobile")` → NaN → 0, and a patch-bump would + # produce `0.0.1` — exactly the wedge on 2026-05-04 (run + # 25304336767). Any non-desktop tag shape must be excluded here. latest_stable="$(gh release list \ --repo "$GITHUB_REPOSITORY" \ --exclude-drafts \ --limit 50 \ --json tagName \ - --jq '[.[] | .tagName | select(test("-rc\\.") | not)] | .[0] // ""')" + --jq '[.[] | .tagName | select(test("^v[0-9]") and (test("-rc\\.") | not))] | .[0] // ""')" latest_stable="${latest_stable#v}" echo "Latest stable: ${latest_stable:-}" @@ -302,13 +310,16 @@ jobs: # already exist — that's a real conflict (someone tagged manually # over a shipped version) and needs human attention. if git rev-parse "v$new" >/dev/null 2>&1; then - # Use the REST API, not `gh release view`: the latter returns - # "release not found" for drafts *and* for absent releases, - # which would cause us to "recover" over a draft that a human - # is preparing. The API distinguishes them. - release_state="$(gh api \ + # Why: use the REST API, not `gh release view`. `gh api` returns + # `true`/`false` for a real release, while a 404 means the tag is + # orphaned and can be safely re-dispatched. Capture failures + # explicitly so the 404 payload never leaks into the state check. + release_state="missing" + if gh api \ "repos/$GITHUB_REPOSITORY/releases/tags/v$new" \ - --jq '.draft' 2>/dev/null || echo "missing")" + --jq '.draft' >"$RUNNER_TEMP/release-state" 2>/dev/null; then + release_state="$(cat "$RUNNER_TEMP/release-state")" + fi case "$release_state" in missing|true) echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag." @@ -369,10 +380,292 @@ jobs: git push origin "$TAG" fi - release: + create-release: needs: cut if: needs.cut.outputs.should_release == 'true' - uses: ./.github/workflows/release.yml + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + - name: Create draft release with auto-generated notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.cut.outputs.tag }} + run: | + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists." + exit 0 + fi + + is_rc=false + if [[ "$TAG" == *"-rc."* ]]; then + is_rc=true + fi + + gh release create "$TAG" \ + --draft \ + --generate-notes \ + --prerelease="$is_rc" + + # Why: E2E runs alongside the release for visibility (failures surface as a + # red check on the tag), but is NOT in `publish-release`'s needs list. + # Releases already take a while and the suite is already a required check + # on PRs, so gating here would mostly delay shipping without adding signal. + e2e: + needs: cut + if: needs.cut.outputs.should_release == 'true' + uses: ./.github/workflows/e2e.yml + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + build: + needs: + - cut + - create-release + if: needs.cut.outputs.should_release == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + platform: mac + release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + eb_cache_path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + - os: windows-latest + platform: win + release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always + eb_cache_path: | + ~\AppData\Local\electron\Cache + ~\AppData\Local\electron-builder\Cache + - os: ubuntu-latest + platform: linux + release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always + eb_cache_path: | + ~/.cache/electron + ~/.cache/electron-builder + + runs-on: ${{ matrix.os }} + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + + # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: pnpm + + # Why: release builds hit the same native-module postinstall path as + # PR CI, so keep the pinned node-gyp override here too instead of + # relying on pnpm's bundled copy. Scoped to Linux via runner.os (not + # a specific matrix image) because the failing postinstall has only + # been observed on Linux runners — see run 25081763129. The macOS + # and Windows release jobs exercise the same pnpm install path and + # have not reproduced it, so keep the gate narrow until we know why. + # Using runner.os instead of matrix.os == 'ubuntu-latest' means the + # gate still works if another Linux matrix entry is added later. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + # Cache the Electron binary + electron-builder tool downloads (notarytool, + # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. + - name: Cache electron-builder downloads + uses: actions/cache@v4 + with: + path: ${{ matrix.eb_cache_path }} + key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-${{ matrix.platform }}- + + # Why: pnpm install triggers electron's postinstall, which downloads the + # Electron binary from GitHub release assets. GitHub's download CDN + # occasionally returns 504s that fail the whole release. Retry on + # failure so transient network errors don't require a manual re-run. + - name: Install dependencies + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # 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. + - 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 + + - name: Verify macOS signing environment + if: matrix.platform == 'mac' + run: node config/scripts/verify-macos-release-env.mjs + env: + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`) + # requires the build identity to be the literal string `stable` or `rc`, + # substituted by electron-vite's `define` block at build time. Derive + # that identity from the release tag here — `stable` for plain semver + # (`vX.Y.Z`), `rc` for prerelease (`vX.Y.Z-rc.N`). The strict regex is + # a safety net: this workflow only fires on cut-tags that already match + # one of those shapes, but if a future change ever loosens that, we + # refuse to ship rather than let an unclassified build go out with + # `BUILD_IDENTITY = null`. + - name: Classify release tag for telemetry build identity + id: tag-classify + shell: bash + env: + TAG: ${{ needs.cut.outputs.tag }} + run: | + set -euo pipefail + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + identity=rc + elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + identity=stable + else + echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact" + exit 1 + fi + echo "identity=$identity" >>"$GITHUB_OUTPUT" + echo "Classified $TAG as $identity" + + # Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that + # produces a published binary, so this is the only place the secret + # needs to be in scope. The key is a PostHog *project* API key, not + # a server secret — it ships in every official binary's app.asar + # and is therefore extractable from any release. We still keep it + # in GitHub Actions secrets so the literal stays out of the repo + # (and out of fork CI runs / log scrapers / casual greps). + # Why ORCA_BUILD_IDENTITY here (not in env at the job level): the + # value comes from the per-tag classification above and electron-vite + # reads it from `process.env` during `pnpm build:release` only. + - name: Build app + run: pnpm build:release + env: + ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }} + ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} + + # Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be + # passed to non-macOS builds. electron-builder uses CSC_LINK as the + # code-signing certificate on any platform, so leaking the Apple + # Developer ID cert to the Windows build causes the NSIS installer to + # be signed with an Apple cert whose chain Windows cannot validate, + # breaking the auto-updater with "certificate chain could not be built + # to a trusted root authority" (issue #631). + # + # Why retry: electron-builder downloads NSIS/winCodeSign/squirrel + # binaries and the Electron runtime from GitHub release assets during + # publish. GitHub's download CDN occasionally returns 504s that fail + # the whole release. Retry on failure so transient network errors + # don't require a manual re-run. + - name: Publish release artifacts (macOS) + if: matrix.platform == 'mac' + uses: nick-fields/retry@v3 + with: + timeout_minutes: 45 + max_attempts: 3 + retry_wait_seconds: 30 + command: ${{ matrix.release_command }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Publish release artifacts + if: matrix.platform != 'mac' + uses: nick-fields/retry@v3 + with: + timeout_minutes: 30 + max_attempts: 3 + retry_wait_seconds: 30 + command: ${{ matrix.release_command }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Why post-publish (not pre-publish): electron-builder packs and + # uploads in a single `--publish always` invocation, so there is no + # cheap insertion point between pack and upload without splitting + # that step. Running verify last still blocks the bad release: the + # binary is uploaded to the draft, but a failed matrix job blocks + # the `publish-release` job (which depends on `build`) from flipping + # the release from draft → published, so users never see it. A human + # then deletes the draft and re-cuts. + # + # Why this guards against: a misconfigured CI run where + # `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify + # would otherwise produce a binary with `BUILD_IDENTITY = null` and + # `WRITE_KEY = null`, which silently disables transport + # (`IS_OFFICIAL_BUILD === false`) — the exact failure mode flagged + # in PR #1385's deferred follow-up. + - name: Verify telemetry constants present in app.asar + run: node config/scripts/verify-telemetry-constants.mjs + + publish-release: + needs: + - cut + - build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Publish release + # Why: derive `--prerelease` from the tag shape (not from whatever + # electron-builder left the release flagged as). On 2026-04-27, + # electron-builder's publish step flipped `prerelease` back to + # `false` on -rc.N releases, which caused an RC to be marked as + # GitHub's "latest" release and broke release-cut.yml's math. + # Re-asserting here means the final release state is determined + # by the tag — a ground truth electron-builder can't rewrite. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.cut.outputs.tag }} + run: | + set -euo pipefail + if [[ "$TAG" == *"-rc."* ]]; then + prerelease=true + else + prerelease=false + fi + gh release edit "$TAG" \ + --draft=false \ + --prerelease="$prerelease" \ + --repo "$GITHUB_REPOSITORY" + + homebrew-bump: + needs: + - cut + - publish-release + if: ${{ !contains(needs.cut.outputs.tag, '-rc.') }} + uses: ./.github/workflows/homebrew-bump.yml with: tag: ${{ needs.cut.outputs.tag }} secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1706afbc9b8..00000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,257 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - workflow_call: - inputs: - tag: - description: Tag to release, for example v1.2.3-rc.0 - required: true - type: string - workflow_dispatch: - inputs: - tag: - description: Existing tag to release, for example v1.2.3-rc.0 - required: true - type: string - -jobs: - resolve-release: - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.resolve.outputs.tag }} - ref: ${{ steps.resolve.outputs.ref }} - steps: - - name: Resolve release tag and ref - id: resolve - run: | - if [[ "${{ github.event_name }}" == "push" ]]; then - release_tag="${{ github.ref_name }}" - release_ref="${{ github.ref }}" - else - release_tag="${{ inputs.tag }}" - release_ref="refs/tags/${{ inputs.tag }}" - fi - - echo "tag=$release_tag" >>"$GITHUB_OUTPUT" - echo "ref=$release_ref" >>"$GITHUB_OUTPUT" - - create-release: - needs: resolve-release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ needs.resolve-release.outputs.ref }} - - - name: Create draft release with auto-generated notes - run: | - if gh release view "${{ needs.resolve-release.outputs.tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then - echo "Release ${{ needs.resolve-release.outputs.tag }} already exists." - exit 0 - fi - - gh release create "${{ needs.resolve-release.outputs.tag }}" \ - --draft \ - --generate-notes \ - --prerelease=${{ contains(needs.resolve-release.outputs.tag, 'rc') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Why: E2E runs alongside the release for visibility (failures surface as a - # red check on the tag), but it is NOT in `release`'s needs list. Releases - # already take a while and the suite is already a required check on PRs, so - # gating here would mostly just delay shipping without adding much signal. - # Matches the pattern used by noqa's app deploy, which runs E2E with - # continue-on-error so failures are visible but don't block the deploy. - e2e: - needs: resolve-release - uses: ./.github/workflows/e2e.yml - with: - ref: ${{ needs.resolve-release.outputs.ref }} - - release: - needs: - - resolve-release - - create-release - strategy: - fail-fast: false - matrix: - include: - - os: macos-15 - platform: mac - release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always - eb_cache_path: | - ~/Library/Caches/electron - ~/Library/Caches/electron-builder - - os: windows-latest - platform: win - release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always - eb_cache_path: | - ~\AppData\Local\electron\Cache - ~\AppData\Local\electron-builder\Cache - - os: ubuntu-latest - platform: linux - release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always - eb_cache_path: | - ~/.cache/electron - ~/.cache/electron-builder - - runs-on: ${{ matrix.os }} - - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ needs.resolve-release.outputs.ref }} - - # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: package.json - cache: pnpm - - # Why: release builds hit the same native-module postinstall path as - # PR CI, so keep the pinned node-gyp override here too instead of - # relying on pnpm's bundled copy. Scoped to Linux via runner.os (not - # a specific matrix image) because the failing postinstall has only - # been observed on Linux runners — see run 25081763129. The macOS - # and Windows release jobs exercise the same pnpm install path and - # have not reproduced it, so keep the gate narrow until we know why. - # Using runner.os instead of matrix.os == 'ubuntu-latest' means the - # gate still works if another Linux matrix entry is added later. - - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) - if: runner.os == 'Linux' - run: | - npm install -g node-gyp@11.5.0 - echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - - # Cache the Electron binary + electron-builder tool downloads (notarytool, - # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. - - name: Cache electron-builder downloads - uses: actions/cache@v4 - with: - path: ${{ matrix.eb_cache_path }} - key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - electron-builder-${{ matrix.platform }}- - - # Why: pnpm install triggers electron's postinstall, which downloads the - # Electron binary from GitHub release assets. GitHub's download CDN - # occasionally returns 504s that fail the whole release. Retry on - # failure so transient network errors don't require a manual re-run. - - name: Install dependencies - uses: nick-fields/retry@v3 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile - - - name: Verify macOS signing environment - if: matrix.platform == 'mac' - run: node config/scripts/verify-macos-release-env.mjs - env: - CSC_LINK: ${{ secrets.MAC_CERTS }} - CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - - name: Build app - run: pnpm build:release - - # Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be - # passed to non-macOS builds. electron-builder uses CSC_LINK as the - # code-signing certificate on any platform, so leaking the Apple - # Developer ID cert to the Windows build causes the NSIS installer to - # be signed with an Apple cert whose chain Windows cannot validate, - # breaking the auto-updater with "certificate chain could not be built - # to a trusted root authority" (issue #631). - # - # Why retry: electron-builder downloads NSIS/winCodeSign/squirrel - # binaries and the Electron runtime from GitHub release assets during - # publish. GitHub's download CDN occasionally returns 504s that fail - # the whole release. Retry on failure so transient network errors - # don't require a manual re-run. - - name: Publish release artifacts (macOS) - if: matrix.platform == 'mac' - uses: nick-fields/retry@v3 - with: - timeout_minutes: 45 - max_attempts: 3 - retry_wait_seconds: 30 - command: ${{ matrix.release_command }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_LINK: ${{ secrets.MAC_CERTS }} - CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - - name: Publish release artifacts - if: matrix.platform != 'mac' - uses: nick-fields/retry@v3 - with: - timeout_minutes: 30 - max_attempts: 3 - retry_wait_seconds: 30 - command: ${{ matrix.release_command }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-release: - needs: - - resolve-release - - release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Publish release - # Why: derive `--prerelease` from the tag shape (not from whatever - # electron-builder left the release flagged as). On 2026-04-27, - # electron-builder's publish step flipped `prerelease` back to - # `false` on -rc.N releases, which caused an RC to be marked as - # GitHub's "latest" release and broke release-cut.yml's math. - # Re-asserting here means the final release state is determined - # by the tag — a ground truth electron-builder can't rewrite. - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.resolve-release.outputs.tag }} - run: | - set -euo pipefail - if [[ "$TAG" == *"-rc."* ]]; then - prerelease=true - else - prerelease=false - fi - gh release edit "$TAG" \ - --draft=false \ - --prerelease="$prerelease" \ - --repo "$GITHUB_REPOSITORY" - - homebrew-bump: - needs: - - resolve-release - - publish-release - if: ${{ !contains(needs.resolve-release.outputs.tag, '-rc.') }} - uses: ./.github/workflows/homebrew-bump.yml - with: - tag: ${{ needs.resolve-release.outputs.tag }} - secrets: inherit diff --git a/.gitignore b/.gitignore index a3f366a865f..6bc6a97afeb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ dist-electron/ out/ /build/ release/ +native/**/.build/ # pnpm .pnpm-store/ @@ -67,9 +68,8 @@ design-docs/ .atl/ # Local-only design/planning docs (not checked in) -docs/*-design.md -docs/*-plan.md -docs/design-*.md +docs/*.md +!docs/README*.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/.oxlintrc.json b/.oxlintrc.json index dce97c91041..eb968c10e61 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -7,7 +7,6 @@ "rules": { "react/jsx-no-duplicate-props": "error", "react/jsx-no-undef": "error", - "react/jsx-uses-vars": "error", "react/no-children-prop": "error", "react/no-danger-with-children": "error", "react/no-direct-mutation-state": "error", diff --git a/AGENTS.md b/AGENTS.md index 683b216a3d1..f0979fb2159 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # AGENTS.md +## Design System + +All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. + ## Code Comments: Document the "Why", Briefly When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does. diff --git a/CLAUDE.md b/CLAUDE.md index 656f0c42715..43c994c2d36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,24 +1 @@ -# CLAUDE.md - -See also: [AGENTS.md](./AGENTS.md) for agent-specific rules. - -## Code Comments: Document the "Why" - -When implementing behavior driven by a design doc, spec, or non-obvious constraint, **add a comment explaining why** the code does what it does — not just what it does. This is especially important for: - -- **Safety constraints** — e.g., suppressing an action because it could silently erase a signal, re-create a conflict, or mislead the user. -- **Fallback/error-handling choices** — e.g., defaulting to `'modified'` on fs error because it's the least misleading option. -- **Architectural boundaries** — e.g., why state lives in the renderer and never crosses IPC, or why a feature belongs to Source Control and not Checks. -- **Compatibility shims** — e.g., when a field exists purely for downstream plumbing and does not carry semantic meaning. -- **Intentional omissions** — e.g., skipping submodule conflicts or not providing rename metadata because the data source doesn't support it. - -A future maintainer who hasn't read the design doc should be able to understand from the comment alone why the code must not be changed casually. - -## Package Manager - -This project uses **pnpm**. Never use `npm` or `yarn`. - -- Use `pnpm install` (not `npm install`) -- Use `pnpm add ` (not `npm install `) -- Use `pnpm run - - - -
- - - - -
-

A. Vertical rail

-

Single left rail anchors agents to the parent. No inner card, no divider. Icons 12px.

- -
- -
- - fix-notification-suites -
-
- - stably - - fix-notification-suites -
-

drafted agent-row redesign mockup at agent-row-design.html

- - - - - -
- - -
- - how likely it is to aff… - 8m -
-
Who runs into the behavior change
- -
- - - - fix low-severity spam - just now -
-
-
-
- - - - -
-

B. Flush rows, dot-only status

-

Drop the agent avatar entirely. Status dot (6px) + title is enough. Indented 12px.

- -
-
- - fix-notification-suites -
-
- - stably - - fix-notification-suites -
-

drafted agent-row redesign mockup at agent-row-design.html

- -
-
- Agents - 2 -
- -
- - Implement docs/peek - 8m -
- -
- - fix low-severity spam - now -
-
-
-
- - - - -
-

C. Inset panel

-

Agents live in a slightly darker recessed block — feels like a drawer, not a card-in-card.

- -
-
- - fix-notification-suites -
-
- - stably - - fix-notification-suites -
-

drafted agent-row redesign mockup at agent-row-design.html

- -
-
- Agents · 2 - -
- -
- - how likely it is to aff… - 8m -
- -
- - - - fix low-severity spam - now -
-
-
-
- - - - -
-

D. Inline summary row

-

Default state is a compact one-line summary. Click to expand. Reduces visual noise when scrolling.

- -
-
- - fix-notification-suites -
-
- - stably - - fix-notification-suites -
-

drafted agent-row redesign mockup at agent-row-design.html

- - - - - -
-

Expanded state

-
-
- - how likely it is to aff… - 8m -
-
- - - - fix low-severity spam - now -
-
-
-
-
- - - - -
-

E. Tree branch

-

Explicit hierarchy via L-connectors. Works well when nesting could grow deeper.

- -
-
- - fix-notification-suites -
-
- - stably - - fix-notification-suites -
-

drafted agent-row redesign mockup at agent-row-design.html

- -
- - -
- -
- - - how likely it is to aff… - 8m -
- -
- - - - - fix low-severity spam - now -
-
-
-
-
- - - - -
-

F. Stacked pills

-

Agents feel like chips anchored to the task. Good for 1–3 agents. Compact vertical footprint.

- -
-
- - fix-notification-suites -
-
- - stably - - fix-notification-suites -
-

drafted agent-row redesign mockup at agent-row-design.html

- -
-
- - how likely it is to aff… - 8m -
-
- - - - fix low-severity spam - now -
-
-
-
- -
- - -
-

What changed across every option

-
    -
  • Agent avatar shrunk: 14px sparkle → 11–12px status glyph. In most options the vendor sparkle is removed entirely; the state glyph carries the signal.
  • -
  • No card-in-card. The inner "AGENTS" box with its own border is replaced by a rail, divider, inset panel, tree, or bare list.
  • -
  • Indent aligned under the task title, not offset between icons.
  • -
  • Row height: 22–24px instead of 30+.
  • -
- -

My pick

-

A (rail) or B (flush + dot-only). A still communicates "these belong to the parent" visually. B is the leanest — best if users rarely need to distinguish agent vendor at the child level.

-
- - diff --git a/agent-nesting-stacked.html b/agent-nesting-stacked.html deleted file mode 100644 index e0380d1ad39..00000000000 --- a/agent-nesting-stacked.html +++ /dev/null @@ -1,499 +0,0 @@ - - - - - Worktree cards — stacked list - - - - - -
- - - - -
-

A. Vertical rail

-

Card breaks appear where agents exist.

- -
- - -
-
- - master - primary -
-
- orca-internal - master -
-
- - -
-
- - master - primary - -
-
- orca-internal - master -
-

switched x-automation home-timeline fetch to @JinjingLiang…

- - -
- -
- - - - rebase remote and… - 3m -
-
Rebased onto origin/master (resolved…
-
-
- - -
-
- - main - primary -
-
- jinjing-work - main -
-

finished email triage (processed 35 new emails)…

-
- - -
-
- - fix-notification-suites -
-
- stably - fix-notification-suites -
- - -
- -
- - how likely it is to aff… - 8m -
-
- - - - fix low-severity spam - now -
-
-
- - -
-
- - improve-agent-status-style -
-
- orca - improve-agent-status-style -
-

drafted agent-row redesign mockup at agent-row-design.html…

-
- - -
-
- - feat/full-suite-name-… - primary -
-
- stably - feat/full-suite-name-and-o… -
-

- - PR #153 feat(reporter): remove run… -

-
- -
-
- - - - -
-

B. Flush + dot-only

-

No sparkle icons; status dot is enough.

- -
- -
-
- - master - primary -
-
- orca-internal - master -
-
- -
-
- - master - primary - -
-
- orca-internal - master -
-

switched x-automation home-timeline fetch to @JinjingLiang…

-
-
- Agents - 1 -
-
- - rebase remote and… - 3m -
-
-
- -
-
- - main - primary -
-
- jinjing-work - main -
-

finished email triage (processed 35 new emails)…

-
- -
-
- - fix-notification-suites -
-
- stably - fix-notification-suites -
-
-
- Agents - 2 -
-
- - how likely it is to aff… - 8m -
-
- - fix low-severity spam - now -
-
-
- -
-
- - improve-agent-status-style -
-
- orca - improve-agent-status-style -
-

drafted agent-row redesign mockup at agent-row-design.html…

-
- -
-
- - feat/full-suite-name-… - primary -
-
- stably - feat/full-suite-name-and-o… -
-

PR #153 feat(reporter): remove run…

-
-
-
- - - - -
-

D. Inline summary

-

Agents collapse to one line; click to expand.

- -
- -
-
- - master - primary -
-
- orca-internal - master -
-
- -
-
- - master - primary -
-
- orca-internal - master -
-

switched x-automation home-timeline fetch to @JinjingLiang…

- -
- -
-
- - main - primary -
-
- jinjing-work - main -
-

finished email triage (processed 35 new emails)…

-
- - -
-
- - fix-notification-suites -
-
- stably - fix-notification-suites -
- -
-
- - how likely it is to aff… - 8m -
-
- - - - fix low-severity spam - now -
-
-
- -
-
- - improve-agent-status-style -
-
- orca - improve-agent-status-style -
-

drafted agent-row redesign mockup at agent-row-design.html…

-
- -
-
- - feat/full-suite-name-… - primary -
-
- stably - feat/full-suite-name-and-o… -
-

PR #153 feat(reporter): remove run…

-
-
-
- - - - -
-

G. Merged card

-

Agent rows become peer children of the card. No label, no divider.

- -
- -
-
- - master - primary -
-
- orca-internal - master -
-
- -
-
- - master - primary -
-
- orca-internal - master -
-

switched x-automation home-timeline fetch to @JinjingLiang…

-
-
- - - - rebase remote and… - 3m -
-
-
- -
-
- - main - primary -
-
- jinjing-work - main -
-

finished email triage (processed 35 new emails)…

-
- -
-
- - fix-notification-suites -
-
- stably - fix-notification-suites -
-
-
- - how likely it is to aff… - 8m -
-
- - - - fix low-severity spam - now -
-
-
- -
-
- - improve-agent-status-style -
-
- orca - improve-agent-status-style -
-

drafted agent-row redesign mockup at agent-row-design.html…

-
- -
-
- - feat/full-suite-name-… - primary -
-
- stably - feat/full-suite-name-and-o… -
-

PR #153 feat(reporter): remove run…

-
-
-
- -
- -
-

Scan test

-

With a stack of 6 cards: B and G scan fastest — uniform card height, agents feel like part of the task. A adds one extra visual feature (rail) that reads as deliberate hierarchy. D wins when lists get long (20+ worktrees) — agents stay one line until you care.

-
- - - diff --git a/agent-row-design.html b/agent-row-design.html deleted file mode 100644 index 207f08d237c..00000000000 --- a/agent-row-design.html +++ /dev/null @@ -1,224 +0,0 @@ - - - - - Agent row — spacing polish only - - - - -
- - -
-

Before

-
-
- - AGENTS (3) -
-
-
-
- - - - - yes - 1m - - -
-
Revert pushed as 610ffbadb2. Gate the migration behind flag.
-
-
-
-
- - -
-

After — spacing only

- - - -
- - - -
- - -
-
- - - - - - - implement retry logic for websocket disconnects - - 12s - - - -
-
- - Bash - pnpm test src/ws/*.test.ts --watch -
-
- - -
-
- - - - - - - Revert pushed as 610ffbadb2. Gate the migration. - 2m -
-
Post-deploy health check passed; ready for review.
-
- - -
-
- - - - - - - Approve running rm -rf dist/? - just now -
-
Waiting for your input
-
- - -
-
- - - - - - - local.env points to staging - interrupted - 8m -
-
Post-deploy health check (last 45 minutes)
-
- - -
-
- - - - - - - Idle - 1h -
-
- -
-
- - -

Dark

-
-
- - Agents (3) -
-
- -
-
- - - implement retry logic for websocket disconnects - 12s -
-
- - Bash - pnpm test src/ws/*.test.ts --watch -
-
- -
-
- - - - - Revert pushed as 610ffbadb2. Gate the migration. - 2m -
-
Post-deploy health check passed; ready for review.
-
- -
-
- - - Approve running rm -rf dist/? - just now -
-
Waiting for your input
-
- -
-
-
- -
-

Spacing diff

-
    -
  • Row padding py-0.5 px-1 → py-1.5 px-2. Rows breathe.
  • -
  • Inner gap gap-1.5 → gap-2. State dot and agent icon no longer collide.
  • -
  • State slot normalized to h-3 w-3; agent icon stays 14px — consistent left edge.
  • -
  • Subtitle indent pl-5 → pl-[26px] to line up under the agent icon, not between the two glyphs.
  • -
  • Title leading-[18px], subtitle leading-[16px]. Fixes cramped leading-snug on 10–11px text.
  • -
  • Dividers removed; hover background separates rows.
  • -
  • Always-on dismiss/expand glyphs → hover-only, with 2px chip padding for a real 24×24 hit target.
  • -
  • Title weight: font-semibold text-foreground when unvisited or needs-attention, text-muted-foreground normal when read — stops read rows from shouting.
  • -
-
-
- - diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index aae41fe4025..62a044e808e 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,5 +1,6 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs') -const { join } = require('node:path') +const { execFileSync } = require('node:child_process') +const { join, resolve } = require('node:path') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' @@ -35,6 +36,7 @@ module.exports = { 'out/cli/**', 'out/shared/**', 'out/main/daemon-entry.js', + 'out/main/computer-sidecar.js', 'out/main/chunks/**', 'resources/**', 'node_modules/zod/**' @@ -42,7 +44,12 @@ module.exports = { afterPack: async (context) => { const resourcesDir = context.electronPlatformName === 'darwin' - ? join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources') + ? join( + context.appOutDir, + `${context.packager.appInfo.productFilename}.app`, + 'Contents', + 'Resources' + ) : join(context.appOutDir, 'resources') if (!existsSync(resourcesDir)) { return @@ -56,6 +63,9 @@ module.exports = { // the copied binary to be executable in packaged apps. chmodSync(join(resourcesDir, filename), 0o755) } + if (context.electronPlatformName === 'darwin') { + await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager) + } }, win: { executableName: 'Orca', @@ -67,6 +77,10 @@ module.exports = { { from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe', to: 'agent-browser-win32-x64.exe' + }, + { + from: 'native/computer-use-windows/runtime.ps1', + to: 'computer-use-windows/runtime.ps1' } ] }, @@ -115,6 +129,10 @@ module.exports = { { from: 'node_modules/agent-browser/bin/agent-browser-darwin-${arch}', to: 'agent-browser-darwin-${arch}' + }, + { + from: 'native/computer-use-macos/.build/release/Orca Computer Use.app', + to: 'Orca Computer Use.app' } ], target: [ @@ -134,6 +152,9 @@ module.exports = { dmg: { artifactName: 'orca-macos-${arch}.${ext}' }, + deb: { + depends: ['python3', 'python3-gi', 'gir1.2-atspi-2.0', 'at-spi2-core', 'xdotool', 'xclip'] + }, linux: { extraResources: [ { @@ -143,6 +164,10 @@ module.exports = { { from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}', to: 'agent-browser-linux-${arch}' + }, + { + from: 'native/computer-use-linux/runtime.py', + to: 'computer-use-linux/runtime.py' } ], target: ['AppImage', 'deb'], @@ -165,3 +190,67 @@ module.exports = { releaseType: 'release' } } + +async function signMacComputerUseHelper(helperAppPath, packager) { + if (!existsSync(helperAppPath)) { + if (isMacRelease) { + throw new Error(`Missing Orca Computer Use helper app at ${helperAppPath}`) + } + return + } + const codeSigningInfo = + isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value + ? await packager.codeSigningInfo.value + : null + const identity = + process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ?? + process.env.CSC_NAME ?? + findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ?? + (isMacRelease ? null : '-') + if (!identity) { + throw new Error('Missing signing identity for Orca Computer Use helper app') + } + // Why: TCC grants attach to this nested app's code identity. Sign it before + // the outer Orca.app is sealed so production builds preserve that identity. + execFileSync('codesign', codesignArgs(identity, helperAppPath), { stdio: 'inherit' }) + execFileSync('codesign', ['--verify', '--deep', '--strict', helperAppPath], { + stdio: 'inherit' + }) +} + +function codesignArgs(identity, targetPath) { + const args = ['--force', '--deep', '--sign', identity] + if (isMacRelease) { + args.push( + '--options', + 'runtime', + '--timestamp', + '--entitlements', + resolve(__dirname, '../resources/build/entitlements.computer-use.mac.plist') + ) + } + args.push(targetPath) + return args +} + +function findInstalledMacSigningIdentity(keychainFile) { + try { + const output = execFileSync( + 'security', + ['find-identity', '-v', '-p', 'codesigning', ...(keychainFile ? [keychainFile] : [])], + { + encoding: 'utf8' + } + ) + const releaseMatch = + output.match(/"([^"]*Developer ID Application:[^"]+)"/) ?? + output.match(/"([^"]*Apple Distribution:[^"]+)"/) + if (releaseMatch?.[1]) { + return releaseMatch[1] + } + if (!isMacRelease) { + return output.match(/"([^"]*Apple Development:[^"]+)"/)?.[1] ?? null + } + } catch {} + return null +} diff --git a/config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch b/config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch similarity index 100% rename from config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch rename to config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 4ff713f6c2b..f52544bcf1c 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -74,17 +74,276 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b var DEFAULT_NAME = 'xterm'; var DESTROY_SOCKET_TIMEOUT_MS = 200; diff --git a/src/unix/pty.cc b/src/unix/pty.cc -index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..c17decbb7bc06f10d7b5d26317bd68fdc8c4e339 100644 +index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644 --- a/src/unix/pty.cc +++ b/src/unix/pty.cc -@@ -778,8 +778,8 @@ done: - posix_spawn_file_actions_destroy(&acts); - posix_spawnattr_destroy(&attrs); +@@ -23,7 +23,9 @@ + #include + #include + #include ++#include + #include ++#include + #include + + #include +@@ -237,13 +239,23 @@ pty_getproc(int, char *); + #endif + + #if defined(__APPLE__) || defined(__OpenBSD__) ++struct pty_spawn_error { ++ const char* step; ++ int errnum; ++ std::string detail_name; ++ std::string detail_value; ++}; ++ ++static std::string ++pty_format_spawn_error(const pty_spawn_error&); ++ + static void + pty_posix_spawn(char** argv, char** env, + const struct termios *termp, + const struct winsize *winp, + int* master, + pid_t* pid, +- int* err); ++ pty_spawn_error* err); + #endif + + struct DelBuf { +@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { + argv[i + 3] = strdup(arg.c_str()); + } + +- int err = -1; +- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err); +- if (err != 0) { +- throw Napi::Error::New(napiEnv, "posix_spawnp failed."); ++ pty_spawn_error spawn_error = { NULL, 0, "", "" }; ++ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error); ++ if (spawn_error.errnum != 0) { ++ std::string spawn_message = pty_format_spawn_error(spawn_error); ++ throw Napi::Error::New(napiEnv, spawn_message); + } + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); +@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) { + #endif + + #if defined(__APPLE__) ++static const char* ++pty_errno_name(int errnum) { ++ switch (errnum) { ++ case E2BIG: return "E2BIG"; ++ case EACCES: return "EACCES"; ++ case EAGAIN: return "EAGAIN"; ++ case EMFILE: return "EMFILE"; ++ case ENFILE: return "ENFILE"; ++ case ENOENT: return "ENOENT"; ++ case ENOMEM: return "ENOMEM"; ++ default: return "errno"; ++ } ++} ++ ++static void ++pty_set_spawn_error(pty_spawn_error* err, ++ const char* step, ++ int errnum, ++ const char* detail_name = NULL, ++ const char* detail_value = NULL) { ++ err->step = step; ++ err->errnum = errnum; ++ err->detail_name = detail_name ? detail_name : ""; ++ err->detail_value = detail_value ? detail_value : ""; ++} ++ ++static std::string ++pty_format_spawn_error(const pty_spawn_error& err) { ++ char errno_buf[64]; ++ snprintf(errno_buf, sizeof(errno_buf), "%d", err.errnum); ++ ++ std::string message = "node-pty: "; ++ message += err.step ? err.step : "unknown"; ++ message += " failed: "; ++ message += pty_errno_name(err.errnum); ++ message += " (errno "; ++ message += errno_buf; ++ message += ", "; ++ message += strerror(err.errnum); ++ message += ")"; ++ ++ if (!err.detail_name.empty()) { ++ message += " - "; ++ message += err.detail_name; ++ message += "='"; ++ message += err.detail_value; ++ message += "'"; ++ } ++ ++ return message; ++} ++ + static void + pty_posix_spawn(char** argv, char** env, + const struct termios *termp, + const struct winsize *winp, + int* master, + pid_t* pid, +- int* err) { +- int low_fds[3]; ++ pty_spawn_error* err) { ++ int low_fds[3] = {-1, -1, -1}; + size_t count = 0; ++ int res = -1; ++ int slave = -1; ++ posix_spawn_file_actions_t acts; ++ bool acts_initialized = false; ++ posix_spawnattr_t attrs; ++ bool attrs_initialized = false; + + for (; count < 3; count++) { + low_fds[count] = posix_openpt(O_RDWR); +@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env, + POSIX_SPAWN_SETSID; + *master = posix_openpt(O_RDWR); + if (*master == -1) { +- return; ++ pty_set_spawn_error(err, "posix_openpt", errno); ++ goto done; ++ } ++ ++ res = grantpt(*master); ++ if (res == -1) { ++ pty_set_spawn_error(err, "grantpt", errno); ++ goto done; + } + +- int res = grantpt(*master) || unlockpt(*master); ++ res = unlockpt(*master); + if (res == -1) { +- return; ++ pty_set_spawn_error(err, "unlockpt", errno); ++ goto done; + } + + // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems. +- int slave; + char slave_pty_name[128]; + res = ioctl(*master, TIOCPTYGNAME, slave_pty_name); + if (res == -1) { +- return; ++ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno); ++ goto done; + } + + slave = open(slave_pty_name, O_RDWR | O_NOCTTY); + if (slave == -1) { +- return; ++ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name); ++ goto done; + } + + if (termp) { + res = tcsetattr(slave, TCSANOW, termp); + if (res == -1) { +- return; ++ pty_set_spawn_error(err, "tcsetattr", errno, "slave", slave_pty_name); ++ goto done; + }; + } + + if (winp) { + res = ioctl(slave, TIOCSWINSZ, winp); + if (res == -1) { +- return; ++ pty_set_spawn_error(err, "ioctl_TIOCSWINSZ", errno, "slave", slave_pty_name); ++ goto done; + } + } + +- posix_spawn_file_actions_t acts; +- posix_spawn_file_actions_init(&acts); ++ res = posix_spawn_file_actions_init(&acts); ++ if (res != 0) { ++ pty_set_spawn_error(err, "posix_spawn_file_actions_init", res); ++ goto done; ++ } ++ acts_initialized = true; + posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO); + posix_spawn_file_actions_addclose(&acts, slave); + posix_spawn_file_actions_addclose(&acts, *master); + +- posix_spawnattr_t attrs; +- posix_spawnattr_init(&attrs); +- *err = posix_spawnattr_setflags(&attrs, flags); +- if (*err != 0) { ++ res = posix_spawnattr_init(&attrs); ++ if (res != 0) { ++ pty_set_spawn_error(err, "posix_spawnattr_init", res); ++ goto done; ++ } ++ attrs_initialized = true; ++ res = posix_spawnattr_setflags(&attrs, flags); ++ if (res != 0) { ++ pty_set_spawn_error(err, "posix_spawnattr_setflags", res); + goto done; + } + + sigset_t signal_set; + /* Reset all signal the child to their default behavior */ + sigfillset(&signal_set); +- *err = posix_spawnattr_setsigdefault(&attrs, &signal_set); +- if (*err != 0) { ++ res = posix_spawnattr_setsigdefault(&attrs, &signal_set); ++ if (res != 0) { ++ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res); + goto done; + } + + /* Reset the signal mask for all signals */ + sigemptyset(&signal_set); +- *err = posix_spawnattr_setsigmask(&attrs, &signal_set); +- if (*err != 0) { ++ res = posix_spawnattr_setsigmask(&attrs, &signal_set); ++ if (res != 0) { ++ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res); + goto done; + } + + do +- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); +- while (*err == EINTR); ++ res = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); ++ while (res == EINTR); ++ if (res != 0) { ++ pty_set_spawn_error(err, "posix_spawn", res, "helper", argv[0]); ++ } + done: +- posix_spawn_file_actions_destroy(&acts); +- posix_spawnattr_destroy(&attrs); ++ if (acts_initialized) { ++ posix_spawn_file_actions_destroy(&acts); ++ } ++ if (attrs_initialized) { ++ posix_spawnattr_destroy(&attrs); ++ } ++ if (slave != -1) { ++ close(slave); ++ } ++ if (err->errnum != 0 && *master != -1) { ++ close(*master); ++ *master = -1; ++ } - for (; count > 0; count--) { - close(low_fds[count]); + for (size_t i = 0; i <= count && i < 3; i++) { -+ close(low_fds[i]); ++ if (low_fds[i] != -1) { ++ close(low_fds[i]); ++ } } } #endif diff --git a/config/scripts/build-computer-macos.mjs b/config/scripts/build-computer-macos.mjs new file mode 100644 index 00000000000..6ef3a959ee8 --- /dev/null +++ b/config/scripts/build-computer-macos.mjs @@ -0,0 +1,142 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const packagePath = path.join(repoRoot, 'native', 'computer-use-macos') +const binaryPath = path.join(packagePath, '.build', 'release', 'orca-computer-use-macos') +const appPath = path.join(packagePath, '.build', 'release', 'Orca Computer Use.app') +const appExecutablePath = path.join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos') +const appIconPath = path.join(appPath, 'Contents', 'Resources', 'AppIcon.icns') +const entitlementsPath = path.join( + repoRoot, + 'resources', + 'build', + 'entitlements.computer-use.mac.plist' +) +const bundleId = process.env.ORCA_COMPUTER_MACOS_BUNDLE_ID ?? 'com.stablyai.orca.computer-use' +const displayName = 'Orca Computer Use' +const signingIdentity = resolveSigningIdentity() +const universalTriples = ['arm64-apple-macosx', 'x86_64-apple-macosx'] + +if (process.platform !== 'darwin') { + process.exit(0) +} + +buildUniversalBinary() +chmodSync(binaryPath, 0o755) +createHelperApp() + +function buildUniversalBinary() { + const builtBinaries = universalTriples.map((triple) => { + run('swift', ['build', '-c', 'release', '--package-path', packagePath, '--triple', triple]) + return path.join(packagePath, '.build', triple, 'release', 'orca-computer-use-macos') + }) + mkdirSync(path.dirname(binaryPath), { recursive: true }) + run('lipo', ['-create', ...builtBinaries, '-output', binaryPath]) +} + +function createHelperApp() { + rmSync(appPath, { recursive: true, force: true }) + mkdirSync(path.dirname(appExecutablePath), { recursive: true }) + mkdirSync(path.join(appPath, 'Contents', 'Resources'), { recursive: true }) + copyFileSync(binaryPath, appExecutablePath) + copyFileSync(path.join(repoRoot, 'resources', 'build', 'icon.icns'), appIconPath) + chmodSync(appExecutablePath, 0o755) + writeFileSync(path.join(appPath, 'Contents', 'Info.plist'), infoPlist(), 'utf8') + const signer = spawnSync('codesign', codesignArgs(signingIdentity, appPath), { stdio: 'inherit' }) + if (signer.signal) { + process.kill(process.pid, signer.signal) + } + if (signer.status !== 0) { + process.exit(signer.status ?? 1) + } +} + +function codesignArgs(identity, targetPath) { + const args = ['--force', '--deep', '--sign', identity] + if (process.env.ORCA_MAC_RELEASE === '1' && identity !== '-') { + args.push('--options', 'runtime', '--timestamp', '--entitlements', entitlementsPath) + } + args.push(targetPath) + return args +} + +function resolveSigningIdentity() { + const explicitIdentity = process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ?? process.env.CSC_NAME + if (explicitIdentity) { + return explicitIdentity + } + const identities = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], { + encoding: 'utf8' + }) + if (identities.status !== 0 || !identities.stdout) { + return '-' + } + const developmentMatch = identities.stdout.match(/"([^"]*Apple Development:[^"]+)"/) + if (process.env.ORCA_MAC_RELEASE !== '1' && developmentMatch) { + return developmentMatch[1] + } + const releaseMatch = + identities.stdout.match(/"([^"]*Developer ID Application:[^"]+)"/) ?? + identities.stdout.match(/"([^"]*Apple Distribution:[^"]+)"/) + return releaseMatch?.[1] ?? developmentMatch?.[1] ?? '-' +} + +function run(command, args) { + const result = spawnSync(command, args, { stdio: 'inherit' }) + if (result.signal) { + process.kill(process.pid, result.signal) + } + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +function infoPlist() { + return ` + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + orca-computer-use-macos + CFBundleIdentifier + ${escapePlist(bundleId)} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + AppIcon + CFBundleName + ${escapePlist(displayName)} + CFBundleDisplayName + ${escapePlist(displayName)} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSAccessibilityUsageDescription + Orca Computer Use needs Accessibility permission to read and interact with app interfaces when you ask Orca to use apps. + NSScreenCaptureUsageDescription + Orca Computer Use needs Screen Recording permission to capture app windows when you ask Orca to inspect your screen. + + +` +} + +function escapePlist(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} diff --git a/config/scripts/computer-use-smoke.mjs b/config/scripts/computer-use-smoke.mjs new file mode 100644 index 00000000000..1d5290a7ec0 --- /dev/null +++ b/config/scripts/computer-use-smoke.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const repoRoot = resolve(import.meta.dirname, '..', '..') +const cliPath = resolve(repoRoot, 'out', 'cli', 'index.js') +const args = new Set(process.argv.slice(2)) +const requestedApps = valueFlag('--apps') +const includeScreenshot = args.has('--screenshot') +const session = valueFlag('--session') ?? `computer-smoke-${process.pid}` +const preferredApps = (requestedApps ?? process.env.ORCA_COMPUTER_SMOKE_APPS ?? 'Finder,TextEdit,Spotify,Slack,Microsoft Edge') + .split(',') + .map((app) => app.trim()) + .filter(Boolean) + +if (!existsSync(cliPath)) { + fail(`Missing built CLI at ${cliPath}. Run pnpm build:cli first.`) +} + +const list = unwrapResult(runCli(['computer', 'list-apps', '--json'])) +const apps = Array.isArray(list.apps) ? list.apps : [] +const availableNames = new Set(apps.map((app) => String(app.name ?? '').toLowerCase())) +const availableBundles = new Set(apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean)) +const targets = preferredApps.filter((app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase())) + +console.log(`computer-use smoke: ${apps.length} apps listed`) +if (targets.length === 0) { + console.log(`computer-use smoke: no preferred apps are running (${preferredApps.join(', ')})`) + process.exit(0) +} + +let failures = 0 +for (const app of targets) { + const result = runCli([ + 'computer', + 'get-app-state', + '--session', + session, + '--app', + app, + ...(includeScreenshot ? [] : ['--no-screenshot']), + '--json' + ], { allowFailure: true }) + + if (!result.ok) { + failures += 1 + console.log(`computer-use smoke: ${app}: failed: ${result.error}`) + continue + } + + const state = unwrapResult(result.value) + const snapshot = state.snapshot + const treeText = String(snapshot.treeText ?? '') + const lineCount = treeText.split('\n').filter(Boolean).length + const secondaryActions = (treeText.match(/Secondary Actions:/g) ?? []).length + const settable = (treeText.match(/\bsettable\b/g) ?? []).length + const screenshotState = state.screenshot ? `${state.screenshot.width}x${state.screenshot.height}` : 'missing' + console.log([ + `computer-use smoke: ${snapshot.app.name}`, + `${snapshot.elementCount} elements`, + `${lineCount} lines`, + `${secondaryActions} secondary-action lines`, + `${settable} settable elements`, + `screenshot=${screenshotState}` + ].join(' | ')) +} + +if (failures > 0) { + fail(`${failures} app snapshot smoke check(s) failed`) +} + +function valueFlag(name) { + const index = process.argv.indexOf(name) + if (index === -1) { + return null + } + return process.argv[index + 1] ?? null +} + +function runCli(cliArgs, options = {}) { + const child = spawnSync(process.execPath, [cliPath, ...cliArgs], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + ORCA_USER_DATA_PATH: process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath() + } + }) + if (child.status !== 0) { + const error = (child.stderr || child.stdout || `exit ${child.status}`).trim() + if (options.allowFailure) { + return { ok: false, error } + } + fail(error) + } + try { + return options.allowFailure + ? { ok: true, value: JSON.parse(child.stdout) } + : JSON.parse(child.stdout) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + fail(`Could not parse CLI JSON for ${cliArgs.join(' ')}: ${detail}\n${child.stdout}`) + } +} + +function defaultDevUserDataPath() { + if (process.platform === 'darwin') { + return resolve(homedir(), 'Library', 'Application Support', 'orca-dev') + } + if (process.platform === 'win32') { + return resolve(process.env.APPDATA ?? resolve(homedir(), 'AppData', 'Roaming'), 'orca-dev') + } + return resolve(process.env.XDG_CONFIG_HOME ?? resolve(homedir(), '.config'), 'orca-dev') +} + +function unwrapResult(value) { + if (value && typeof value === 'object' && 'result' in value) { + return value.result + } + return value +} + +function fail(message) { + console.error(`computer-use smoke: ${message}`) + process.exit(1) +} diff --git a/config/scripts/dev-fresh-profile.sh b/config/scripts/dev-fresh-profile.sh new file mode 100755 index 00000000000..3579c840faa --- /dev/null +++ b/config/scripts/dev-fresh-profile.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Launch `pn dev` with a fresh, isolated userData profile so the app behaves +# like a first-time install (onboarding overlay paints, no persisted repos, +# no saved sessions). Your real `orca-dev` profile is left untouched. +# +# Usage: +# ./config/scripts/dev-fresh-profile.sh # ephemeral temp profile, deleted on exit +# ./config/scripts/dev-fresh-profile.sh --keep # keep the profile dir after exit +# ORCA_FRESH_PROFILE_DIR=/some/path ./config/scripts/dev-fresh-profile.sh # use a fixed dir +set -euo pipefail + +KEEP=0 +for arg in "$@"; do + case "$arg" in + --keep) KEEP=1 ;; + -h|--help) + sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) + echo "Unknown argument: $arg" >&2 + echo "Usage: $0 [--keep] [--help]" >&2 + exit 2 ;; + esac +done + +PROFILE_DIR="${ORCA_FRESH_PROFILE_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/orca-fresh-profile.XXXXXXXX")}" +mkdir -p "$PROFILE_DIR" + +cleanup() { + if [[ "$KEEP" -eq 0 && -z "${ORCA_FRESH_PROFILE_DIR:-}" ]]; then + # Guard rm -rf against accidental empty/unrelated PROFILE_DIR. + [[ -n "${PROFILE_DIR:-}" && -d "$PROFILE_DIR" && "$PROFILE_DIR" == */orca-fresh-profile* ]] || return 0 + rm -rf "$PROFILE_DIR" + echo "[dev-fresh-profile] removed $PROFILE_DIR" + else + echo "[dev-fresh-profile] kept $PROFILE_DIR" + fi +} +trap cleanup EXIT + +echo "[dev-fresh-profile] using userData=$PROFILE_DIR" +# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up. +ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" pnpm dev diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index ecfe6dfcd76..bb0d8707daf 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -20,7 +20,8 @@ */ import { rebuild } from '@electron/rebuild' -import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' const projectDir = process.cwd() @@ -41,6 +42,40 @@ if (ignoreModules.length > 0) { const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features'] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) +// Why: cpu-features ships without `buildcheck.gypi`; its own `install` script +// generates it by running `node buildcheck.js > buildcheck.gypi` before +// node-gyp. @electron/rebuild with `force: true` invokes node-gyp directly +// and bypasses that install hook, so if the file is missing (fresh install, +// store prune, or a prior failed run) node-gyp aborts with +// "buildcheck.gypi not found". Regenerate it here before rebuilding. +if (!ignoreModules.includes('cpu-features')) { + const cpuFeatureDirs = globSync( + 'node_modules/.pnpm/cpu-features@*/node_modules/cpu-features', + { cwd: projectDir }, + ) + for (const relDir of cpuFeatureDirs) { + const dir = resolve(projectDir, relDir) + const gypiPath = resolve(dir, 'buildcheck.gypi') + if (existsSync(gypiPath)) { + continue + } + try { + const out = execFileSync(process.execPath, ['buildcheck.js'], { + cwd: dir, + encoding: 'utf8', + }) + writeFileSync(gypiPath, out) + console.log(`[rebuild] Generated ${relDir}/buildcheck.gypi`) + } catch (/** @type {any} */ err) { + console.error( + `[rebuild] Failed to generate ${relDir}/buildcheck.gypi:`, + err?.message ?? err, + ) + process.exit(1) + } + } +} + try { await rebuild({ buildPath: projectDir, diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 1bfcb87c6de..408206b9838 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -1,5 +1,7 @@ import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' +import net from 'node:net' import { createRequire } from 'node:module' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -61,7 +63,83 @@ if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') { const electronViteCli = process.env.ORCA_ELECTRON_VITE_CLI || path.join(path.dirname(require.resolve('electron-vite/package.json')), 'bin', 'electron-vite.js') -const forwardedArgs = ['dev', ...process.argv.slice(2)] + +// Why: every `pn dev` should be attachable from agent-browser/playwright-cli +// without manual port juggling. Pick a best-effort deterministic port per +// worktree; falls back to a probe sweep if the deterministic pick or its +// neighbors are busy (multiple worktrees may share a machine). +const forwardedRaw = process.argv.slice(2) +function isPortFree(port) { + return new Promise((resolve) => { + const srv = net.createServer() + srv.once('error', () => { + // Why: error fires before listen binds; close() may throw — swallow it + // so the handle is released without leaking listeners across 64 probes. + try { + srv.close() + } catch {} + resolve(false) + }) + srv.once('listening', () => srv.close(() => resolve(true))) + srv.listen(port, '127.0.0.1') + }) +} +async function pickDebugPort() { + // Why: 32 bits of SHA1 (vs 16) reduces truncation bias; modulo 200 still + // collides routinely across many worktrees, hence the probe sweep below. + const seed = parseInt(createHash('sha1').update(repoRoot).digest('hex').slice(0, 8), 16) + const base = 9333 + (seed % 200) // deterministic base in 9333..9532; probe sweeps up to base+63 + for (let i = 0; i < 64; i++) { + const p = base + i + if (await isPortFree(p)) { + return p + } + } + return null +} +function parseDebugPortEnv(raw) { + const n = Number.parseInt(raw, 10) + if (!Number.isInteger(n) || n < 1 || n > 65535 || String(n) !== raw.trim()) { + return null + } + return n +} +// Why: exact match (or `=` form) avoids false positives on hypothetical +// `--remote-debugging-port-*` flags; the bare flag also covers the +// space-separated form. `--remote-debugging-pipe` opts into pipe-based +// debugging — don't fight the user's choice by injecting a port. +const userPassedPort = forwardedRaw.some( + (a) => a === '--remote-debugging-port' || a.startsWith('--remote-debugging-port=') || a === '--remote-debugging-pipe' +) +// Why: --help/--version exit immediately; binding a probe socket and printing +// a debug-port line would be noise. +const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version') +let forwardedExtras = [] +if (!userPassedPort && !isHelpOrVersion) { + const envPortRaw = process.env.REMOTE_DEBUGGING_PORT + let port = null + if (envPortRaw) { + port = parseDebugPortEnv(envPortRaw) + if (port === null) { + console.error( + `[orca-dev] Ignoring invalid REMOTE_DEBUGGING_PORT=${JSON.stringify(envPortRaw)}; falling back to probe.` + ) + } + } + if (port === null) { + port = await pickDebugPort() + } + if (port !== null) { + forwardedExtras = [`--remote-debugging-port=${port}`] + // Why: stderr keeps stdout clean for downstream parsing; log uses + // 127.0.0.1 to match the interface we actually probed (localhost may + // resolve to ::1 on IPv6-first hosts). + console.error(`[orca-dev] Remote debugging on http://127.0.0.1:${port}`) + } else { + console.error('[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.') + } +} +const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras] const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], { stdio: 'inherit', env: process.env, diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs new file mode 100644 index 00000000000..4249a893a2f --- /dev/null +++ b/config/scripts/verify-computer-native.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const repoRoot = resolve(import.meta.dirname, '..', '..') +const checks = [ + { + name: 'macOS Swift renderer/provider tests', + command: 'swift', + args: ['test', '--package-path', 'native/computer-use-macos'], + enabled: process.platform === 'darwin' + }, + { + name: 'Linux provider Python syntax', + command: 'python3', + args: ['-m', 'py_compile', 'native/computer-use-linux/runtime.py'], + enabled: true + }, + { + name: 'Linux provider imports', + command: 'python3', + args: [ + '-c', + [ + 'import importlib.util', + 'spec=importlib.util.spec_from_file_location("orca_linux","native/computer-use-linux/runtime.py")', + 'module=importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(module)', + 'print("import-ok")' + ].join(';') + ], + enabled: process.platform === 'linux' + }, + { + name: 'Windows provider PowerShell parse', + command: process.platform === 'win32' ? 'powershell.exe' : 'pwsh', + args: [ + '-NoLogo', + '-NoProfile', + '-Command', + [ + '$errors=$null', + '$tokens=$null', + '[System.Management.Automation.Language.Parser]::ParseFile("native/computer-use-windows/runtime.ps1",[ref]$tokens,[ref]$errors) > $null', + 'if ($errors.Count) { $errors | Format-List *; exit 1 }', + '"parse-ok"' + ].join('; ') + ], + enabled: true + }, + { + name: 'Windows provider handshake', + run: verifyWindowsProviderHandshake, + enabled: process.platform === 'win32' + }, + { + name: 'macOS helper app bundle and signature', + run: verifyMacOSHelperApp, + enabled: process.platform === 'darwin' + } +] + +let failed = false +for (const check of checks) { + if (!check.enabled) { + console.log(`[computer-native] skip ${check.name}`) + continue + } + if (check.run) { + console.log(`[computer-native] ${check.name}`) + if (!check.run()) { + failed = true + } + continue + } + if (!hasCommand(check.command)) { + console.log(`[computer-native] skip ${check.name}: ${check.command} not found`) + continue + } + console.log(`[computer-native] ${check.name}`) + const result = spawnSync(check.command, check.args, { + cwd: repoRoot, + stdio: 'inherit' + }) + if (result.status !== 0 || result.error) { + failed = true + } +} + +if (failed) { + process.exit(1) +} + +function hasCommand(command) { + if (process.platform === 'win32') { + const result = spawnSync('where.exe', [command], { stdio: 'ignore' }) + return result.status === 0 + } + if (existsSync(command)) { + return true + } + const result = spawnSync('/bin/sh', ['-lc', `command -v ${quoteShell(command)}`], { + stdio: 'ignore' + }) + return result.status === 0 +} + +function verifyMacOSHelperApp() { + const appPath = join( + repoRoot, + 'native', + 'computer-use-macos', + '.build', + 'release', + 'Orca Computer Use.app' + ) + if (!existsSync(appPath)) { + console.error( + `[computer-native] missing helper app at ${appPath}; run pnpm build:computer-macos` + ) + return false + } + return run('codesign', ['--verify', '--deep', '--strict', appPath]) +} + +function verifyWindowsProviderHandshake() { + const dir = mkdtempSync(join(tmpdir(), 'orca-computer-use-verify-')) + const operationPath = join(dir, 'operation.json') + try { + writeFileSync(operationPath, JSON.stringify({ tool: 'handshake' }), { mode: 0o600 }) + const result = spawnSync( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + 'native/computer-use-windows/runtime.ps1', + operationPath + ], + { cwd: repoRoot, encoding: 'utf8' } + ) + if (result.status !== 0 || result.error) { + process.stderr.write(result.stderr ?? '') + return false + } + const response = JSON.parse(result.stdout.trim()) + if (response.ok === true && response.capabilities?.protocolVersion === 1) { + console.log('[computer-native] windows-handshake-ok') + return true + } + console.error(`[computer-native] invalid Windows handshake response: ${result.stdout}`) + return false + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + return false + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +function run(command, args) { + if (!hasCommand(command)) { + console.log(`[computer-native] skip ${command}: command not found`) + return true + } + const result = spawnSync(command, args, { + cwd: repoRoot, + stdio: 'inherit' + }) + return result.status === 0 && !result.error +} + +function quoteShell(value) { + return `'${value.replaceAll("'", "'\\''")}'` +} diff --git a/config/scripts/verify-telemetry-constants.mjs b/config/scripts/verify-telemetry-constants.mjs new file mode 100644 index 00000000000..24f36563532 --- /dev/null +++ b/config/scripts/verify-telemetry-constants.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +// Asserts that the telemetry compile-time constants — `ORCA_BUILD_IDENTITY` +// and `ORCA_POSTHOG_WRITE_KEY` — were substituted into the shipped binary. +// Closes the gap PR #1385 flagged: a release built without those env vars +// produces an `IS_OFFICIAL_BUILD === false` binary that ships silently and +// transmits nothing. This script runs after `electron-builder` has packed +// the app and BEFORE the GitHub release is flipped from draft → published. +// A failure here fails the matrix job; the draft release stays in place +// until a human resolves it. +// +// We grep the packed `app.asar`'s `out/main/*.js` files (the same files +// that the runtime loads) rather than the unpacked `out/`. asar is a +// tar-like archive that doesn't transform contents, so the two are +// byte-equivalent today — but verifying the asar protects against any +// future config change that excludes the main bundle from the package. +// +// Forward-compat: while `TELEMETRY_ENABLED` is `false` in the source, the +// bundler dead-code-eliminates the entire transport block, so the +// `BUILD_IDENTITY = "..."` and `WRITE_KEY = "phc_..."` constants do not +// appear in the binary even when env vars are correctly injected. This +// script reads the source flag at build time and skips the assertion in +// that case — the moment the flag flips to `true` (PR #1385), every +// subsequent release is enforced. +// +// Cross-platform note: written in Node so it runs identically on the Mac, +// Linux, and Windows release runners. Locating `app.asar` via `fs.readdir` +// (instead of POSIX `find`) avoids depending on Git Bash on Windows. +// Reading the asar via the programmatic `@electron/asar` API (instead of +// shelling out to `npx asar`) avoids both a network fetch on every run +// and the Windows `.cmd`-shim/`shell: true` workaround. + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +// Why @electron/asar: canonical replacement for the deprecated `asar` package. +// It's transitively available via electron-builder (and pnpm's +// `shamefully-hoist=true` in `.npmrc` flattens it into the root +// `node_modules`). If electron-builder ever drops it, promote this to a +// direct devDependency in package.json. +import { extractFile, listPackage } from '@electron/asar' + +// Why resolve from import.meta.url instead of cwd: a release runner (or a +// developer debugging locally) may invoke this script from a non-root cwd. +// Resolving relative to the script's own location turns a misleading +// "could not parse TELEMETRY_ENABLED flag" parse error into a clear +// file-not-found error, and decouples the script from the caller's cwd. +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..') + +function findAsar(rootDir) { + // Why: electron-builder produces one `app.asar` per platform-arch combo. + // Linux/Windows targets ship one (`dist/linux-unpacked/resources/app.asar`, + // `dist/win-unpacked/resources/app.asar`); macOS dual-arch ships two + // (`dist/mac/Orca.app/Contents/Resources/app.asar` for x64, + // `dist/mac-arm64/Orca.app/Contents/Resources/app.asar` for arm64) because + // `electron-builder.config.cjs` declares `arch: ['x64', 'arm64']`. Both + // arches share the same JS bundle through electron-vite's single `main` + // build, so the constants are identical across them — but verifying every + // match catches the regression where one arch's pack drifts (e.g. a future + // arch-specific bundle split that forgets to thread the `define` block). + const matches = [] + const stack = [rootDir] + while (stack.length > 0) { + const dir = stack.pop() + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + stack.push(fullPath) + } else if (entry.isFile() && entry.name === 'app.asar') { + matches.push(fullPath) + } + } + } + return matches +} + +const distDir = process.argv[2] ?? 'dist' +if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { + console.error(`::error::dist directory not found at ${distDir}`) + process.exit(1) +} + +// Why: with `TELEMETRY_ENABLED = false` in source, Rollup eliminates the +// transport block and the substituted constants vanish from the binary. +// Verifying in that state would always fail. Read the flag from source +// instead of inferring it from the build, so the gate cannot drift. +const clientSrcPath = join(repoRoot, 'src/main/telemetry/client.ts') +const clientSrc = readFileSync(clientSrcPath, 'utf8') +const enabledMatch = /^const\s+TELEMETRY_ENABLED\s*=\s*(true|false)/m.exec(clientSrc) +if (!enabledMatch) { + console.error(`::error::could not parse TELEMETRY_ENABLED flag from ${clientSrcPath}`) + process.exit(1) +} +if (enabledMatch[1] === 'false') { + console.log( + 'TELEMETRY_ENABLED is false in source — transport is dead-code-eliminated, ' + + 'so the BUILD_IDENTITY/WRITE_KEY constants are not expected in the binary. ' + + 'Skipping asar grep. (Once the flag flips to true, this verify becomes enforcing.)' + ) + process.exit(0) +} + +const asarMatches = findAsar(distDir) +if (asarMatches.length === 0) { + console.error(`::error::could not locate app.asar under ${distDir}`) + process.exit(1) +} +console.log(`Found ${asarMatches.length} app.asar payload(s) under ${distDir}:`) +for (const m of asarMatches) { + console.log(` - ${m}`) +} + +// Why these regexes: electron-vite's `define` block substitutes the bare +// identifiers `ORCA_BUILD_IDENTITY` and `ORCA_POSTHOG_WRITE_KEY` with their +// JSON-stringified values at build time. `src/main/telemetry/client.ts` +// then assigns those into module-local consts named `BUILD_IDENTITY` and +// `WRITE_KEY`. electron-vite's main config is not minified (Vite default for +// Electron main builds), so Rollup emits the substituted constants verbatim +// as `const BUILD_IDENTITY = "stable";`. Match that exact emitted shape so a +// regression — e.g. the env var unset and the substitution falling back to +// literal `null` — fails the grep instead of slipping through as a falsy- +// but-stringy value. NOTE: if `build.minify` is ever enabled on the main +// bundle, esbuild/terser will rename top-level consts and this regex must +// be revisited (or replaced with a value-based assertion). +// +// WRITE_KEY char class includes `_` and `-` because PostHog project API +// keys use URL-safe base64 alphabet beyond `phc_`. +const BUILD_IDENTITY_RE = /const\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/ +const WRITE_KEY_RE = /const\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/ + +function verifyAsar(asarPath) { + console.log(`Verifying ${asarPath}`) + + // Why list-then-extract (not a hardcoded path): future electron-vite + // chunking (e.g. `manualChunks`) could move the constants out of + // `out/main/index.js` into `out/main/chunks/telemetry-XYZ.js`. Enumerate + // every `.js` under `out/main/` and concatenate before grepping so the + // verify is resilient to chunking. listPackage builds entries with the + // host `path` module, so on Windows runners separators are backslashes + // (`\out\main\index.js`); normalize to forward slashes for the filter + // and strip the leading separator (of either kind) before passing the + // entry to extractFile. + const allEntries = listPackage(asarPath) + const mainJsEntries = allEntries.filter((p) => { + const normalized = p.replace(/\\/g, '/').replace(/^\/+/, '') + return normalized.startsWith('out/main/') && normalized.endsWith('.js') + }) + + if (mainJsEntries.length === 0) { + console.error(`::error::no .js files found under out/main/ in ${asarPath}`) + return null + } + + const indexJs = mainJsEntries + .map((entry) => { + // extractFile uses the host path module internally; pass the + // host-separator path with the leading separator stripped. + const internal = entry.replace(/^[\\/]+/, '') + return extractFile(asarPath, internal).toString('utf8') + }) + .join('\n') + + const buildIdentityMatch = BUILD_IDENTITY_RE.exec(indexJs) + const writeKeyMatch = WRITE_KEY_RE.exec(indexJs) + + if (!buildIdentityMatch) { + console.error( + `::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}` + ) + const sample = indexJs.match(/.{0,80}BUILD_IDENTITY.{0,80}/g)?.slice(0, 5) ?? [] + for (const line of sample) { + console.error(` ${line.slice(0, 200)}`) + } + return null + } + if (!writeKeyMatch) { + console.error(`::error::PostHog WRITE_KEY missing from ${asarPath}`) + const sample = indexJs.match(/.{0,80}WRITE_KEY.{0,80}/g)?.slice(0, 5) ?? [] + for (const line of sample) { + console.error(` ${line.slice(0, 200)}`) + } + return null + } + + return { asarPath, buildIdentity: buildIdentityMatch[1], writeKey: writeKeyMatch[1] } +} + +// Why verify every match (not just the first): macOS dual-arch produces one +// asar per arch from the same `out/main` bundle, so identical constants are +// expected — but if a future change introduces an arch-specific bundle split +// (or a per-arch `electron-vite build` invocation) that forgets to thread +// the `define` values, only one of the asars would carry the constants and +// the broken arch would ship transmitting nothing. Looping is cheap insurance +// against that class of regression. We additionally require every asar to +// agree on the BUILD_IDENTITY and WRITE_KEY values — a mismatch means the +// matrix shipped inconsistent build-identity claims, or (worse) per-arch +// PostHog keys that would split events across projects, both of which are +// release bugs. +const results = [] +for (const asarPath of asarMatches) { + const result = verifyAsar(asarPath) + if (!result) { + process.exit(1) + } + results.push(result) +} + +const distinctIdentities = new Set(results.map((r) => r.buildIdentity)) +if (distinctIdentities.size > 1) { + console.error( + `::error::asars disagree on BUILD_IDENTITY: ${[...distinctIdentities].join(', ')}` + ) + for (const r of results) { + console.error(` - ${r.asarPath}: ${r.buildIdentity}`) + } + process.exit(1) +} + +const distinctWriteKeys = new Set(results.map((r) => r.writeKey)) +if (distinctWriteKeys.size > 1) { + console.error(`::error::asars disagree on WRITE_KEY across arches`) + for (const r of results) { + console.error(` - ${r.asarPath}: ${r.writeKey.slice(0, 8)}... (length=${r.writeKey.length})`) + } + process.exit(1) +} + +const [first] = results +console.log( + `Telemetry constants verified across ${results.length} asar(s): ` + + `BUILD_IDENTITY="${first.buildIdentity}", ` + + `WRITE_KEY="${first.writeKey.slice(0, 8)}..." (length=${first.writeKey.length})` +) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index ac207b5364b..bee38dc32c9 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -10,6 +10,6 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['src/**/*.test.ts'] + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] } }) diff --git a/docs/README.es.md b/docs/README.es.md index ed58550d249..055ca186c3b 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -31,7 +31,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).

- Claude Code   + Claude Code   Codex   Gemini   Pi   @@ -40,15 +40,16 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*). Goose   Amp   Auggie   + Autohand Code   Charm   Cline   Codebuff   Continue   Cursor   - Droid   + Droid   GitHub Copilot   Kilocode   - Kimi   + Kimi   Kiro   Mistral Vibe   Qwen Code   @@ -96,6 +97,18 @@ yay -S stably-orca-git --- +## [Nuevo] Ver el estado en vivo de todos los agents + +**Ve qué hace cada agent — de un vistazo.** + +Sigue el estado en vivo de cada agent en cada worktree desde un solo lugar. Detecta qué agents están trabajando, esperando entrada o terminados — sin tener que ir clicando por las pestañas. + +

+ Orca Agent Statuses — ve el estado en vivo de todos los agents en los worktrees +

+ +--- + ## [Nuevo] Anotar diff de IA **Comenta directamente sobre los diffs generados por IA.** diff --git a/docs/README.ja.md b/docs/README.ja.md index d92b0c2f3ed..34cbef8b6e3 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -31,7 +31,7 @@ Orca は任意の CLI エージェントに対応しています(*このリストに限定されません*)。

- Claude Code   + Claude Code   Codex   Gemini   Pi   @@ -40,15 +40,16 @@ Orca は任意の CLI エージェントに対応しています(*このリス Goose   Amp   Auggie   + Autohand Code   Charm   Cline   Codebuff   Continue   Cursor   - Droid   + Droid   GitHub Copilot   Kilocode   - Kimi   + Kimi   Kiro   Mistral Vibe   Qwen Code   @@ -96,6 +97,18 @@ yay -S stably-orca-git --- +## [新機能] すべての agent のライブステータスを表示 + +**各 agent が何をしているか、ひと目で把握。** + +すべての worktree にまたがる各 agent のライブステータスを 1 か所で追跡できます。タブを切り替えなくても、作業中・入力待ち・完了済みの agent を一目で把握できます。 + +

+ Orca Agent Statuses — worktree をまたいだ agent のライブステータスを表示 +

+ +--- + ## [新機能] AI Diff への注釈 **AI が生成した Diff に直接コメントできます。** diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 163b1bfb64e..757c1381e35 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -31,7 +31,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。

- Claude Code   + Claude Code   Codex   Gemini   Pi   @@ -40,15 +40,16 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。 Goose   Amp   Auggie   + Autohand Code   Charm   Cline   Codebuff   Continue   Cursor   - Droid   + Droid   GitHub Copilot   Kilocode   - Kimi   + Kimi   Kiro   Mistral Vibe   Qwen Code   @@ -96,6 +97,18 @@ yay -S stably-orca-git --- +## [全新] 查看所有 agent 的实时状态 + +**一眼看清每个 agent 在做什么。** + +在一个地方追踪所有 worktree 中每个 agent 的实时状态。无需点开各个标签页,即可发现哪些 agent 在工作、等待输入或已完成。 + +

+ Orca Agent Statuses — 查看所有 worktree 中 agent 的实时状态 +

+ +--- + ## [全新] 标注 AI Diff **直接在 AI 生成的 diff 上评论。** diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md new file mode 100644 index 00000000000..b41f9f61a74 --- /dev/null +++ b/docs/STYLEGUIDE.md @@ -0,0 +1,277 @@ +# Orca UI Style Guide + +This is the **UI/visual design** doc for Orca — color tokens, typography, component selection, and UX rules. It is *not* an architecture doc; for system-level design see code and inline comments. Token values live in `src/renderer/src/assets/main.css` (canonical); this file documents the *roles and rules* for using them. + +## Overview + +Orca is an Electron desktop app for orchestrating coding agents across git worktrees. The visual identity is **monochrome and quiet** — neutral grays carry the chrome, color is reserved for state (selection ring, destructive, git decorations). The product spends most of its time hosting other people's tools (Monaco, xterm, Markdown previews), so Orca's own UI should recede and frame. + +When in doubt: + +- Reach for **muted/accent/border** before reaching for color. +- Reach for **CSS variables** before hardcoding hex. +- Match the nearest **shadcn primitive** before writing custom CSS. + +## Source of truth + +| Concern | Canonical location | +| -------------------- | ------------------------------------------------ | +| Color tokens | `src/renderer/src/assets/main.css` (`:root`, `.dark`) | +| Tailwind theme bindings | Same file, `@theme inline { … }` block | +| Component primitives | `src/renderer/src/components/ui/` (shadcn-style) | +| App typography / scrollbars / titlebar chrome | Same `main.css` | + +Never hardcode a hex value in component code if a variable already covers it. If a new token is needed, add it to `main.css` (both `:root` and `.dark`), expose it in the `@theme inline` block, then use it. + +## Color roles + +Tokens come in pairs: a **surface** and a **foreground** that meets contrast on it. Always use them together. + +| Role | Use it for | Don't use it for | +| ---------------------------------- | ------------------------------------------------------------- | --------------------------------------------- | +| `background` / `foreground` | App canvas, default text | Cards, popovers, sidebar (have their own) | +| `card` / `card-foreground` | Panels lifted off the canvas | The canvas itself | +| `popover` / `popover-foreground` | Floating menus, dropdowns, hovercards | Inline UI | +| `primary` / `primary-foreground` | The single affirmative action in a flow (Save, Confirm) | Decorative accents; hover states; secondary actions | +| `secondary` / `secondary-foreground` | Lower-emphasis actions next to a primary | The affirmative action | +| `muted` / `muted-foreground` | De-emphasized text, captions, placeholders, disabled chrome | Body copy; primary actions | +| `accent` / `accent-foreground` | Hover/active backgrounds for ghost buttons and list rows | Solid filled buttons (use `secondary` instead) | +| `destructive` / `destructive-foreground` | Delete, discard, irreversible-action buttons; error states | Cancel buttons (Cancel is not destructive) | +| `border` | All hairlines: dividers, input outlines, card edges | Heavy emphasis; that's `ring` | +| `input` | Form field background only | Anywhere outside form fields | +| `ring` | Focus-visible outlines, active selection halos | Persistent decoration | +| `sidebar` (+ variants) | The worktree sidebar and its children | Other panels | +| `editor-surface` | Background of Monaco / markdown editor panes | App chrome | + +The `sidebar` family expands into `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-primary-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, and `--sidebar-ring` — use them inside the worktree sidebar so its hover/selected/focus states stay consistent and don't bleed into other panels. `editor-surface` is its own token (not just `background`) because Monaco and the markdown editor have a slightly darker surface in dark mode to match VS Code conventions; reach for it whenever you're rendering an editor pane. + +### Git decoration colors + +For diff status, file-tree decorations, and the changes view, use the git decoration tokens (mirroring VS Code's palette so users transferring from VS Code aren't surprised): + +| Token | State | +| ------------------------------ | ------------------------------ | +| `--git-decoration-added` | Added / new | +| `--git-decoration-modified` | Modified | +| `--git-decoration-deleted` | Deleted | +| `--git-decoration-renamed` | Renamed | +| `--git-decoration-untracked` | Untracked | +| `--git-decoration-copied` | Copied | + +Use these *only* for git status. Don't reuse them for unrelated state colors — that breaks the convention. + +### List rows: hover, selected, current + +A common point of drift. Use these conventions for any list-style row (worktrees, command palette items, settings nav): + +- **Idle:** transparent background. +- **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`). +- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically. +- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, *plus* a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight. +- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app. + +### Color mixing + +When you need a tint (e.g. a 12% primary wash on hover), use `color-mix` against the existing token, not a new hex: + +```css +background: color-mix(in srgb, var(--primary) 12%, var(--background)); +``` + +This keeps light/dark parity automatic. + +## Typography + +- **Family:** `Geist` is loaded as a single variable woff2 (weight range 100–900). Always reach for `Geist` for sans, never `Inter` or system sans. +- **Mono:** `var(--font-mono)` — used for paths, terminal-adjacent UI, code, and anywhere monospace conveys "this is literal." +- **Body letter-spacing:** `0.01em` (set globally on `body`). Don't override per component. +- **Sizes:** Tailwind's default scale. Common sizes in this repo: + - 11px (uppercase meta, sidebar headers, captions) — pair with `font-weight: 600` and `text-transform: uppercase` and `letter-spacing: 0.05em` for category labels. + - 12px (sub-text, paths, secondary content) + - 13px (sidebar items, dense list rows) + - 14px (default body, button text in `default` size) + +## Radius + +`--radius: 0.625rem` (10px) is the base; the rest are computed (`--radius-sm` = 0.6×, `--radius-md` = 0.8×, `--radius-lg` = 1×, `--radius-xl` = 1.4×, etc.). Buttons and inputs use `rounded-md`; the `Card` primitive uses `rounded-xl`; badges use `rounded-full`. Match the existing primitive's radius rather than introducing a new one. + +## Elevation & shadows + +Orca uses shadows sparingly. Three levels in practice: + +1. **Inset hairline** — `border` + `border` token. The default. Almost everything sits at this level. +2. **Subtle lift** — `shadow-xs` + a single-token border. Outline buttons, embedded cards. +3. **Floating** — `0 10px 24px rgba(0, 0, 0, 0.18)`. Popovers, popups that escape the editor surface. Reserved. + +Don't add a fourth level. If something needs more emphasis than "floating," you're probably reaching for the focus `ring` instead. + +## Components + +Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern: + +- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) +- Use `cn()` for class merging. Pass user `className` last so callers can override. +- Use `class-variance-authority` (CVA) for variants when there are multiple. + +### Buttons (`button.tsx`) + +Variants in priority order: + +| Variant | Use case | +| ------------- | ---------------------------------------------------------------- | +| `default` | The single affirmative action in a flow. | +| `secondary` | Lower-emphasis sibling next to a `default`. | +| `outline` | Toolbar / standalone actions where a filled button feels heavy. | +| `ghost` | Icon buttons, list-row triggers, anywhere chrome should disappear. | +| `link` | Inline text actions inside paragraphs. | +| `destructive` | Delete, discard, irreversible. Never for Cancel. | + +Sizes: `default` (36px), `sm` (32px), `xs` (24px), `lg` (40px), plus `icon`, `icon-xs`, `icon-sm`, `icon-lg`. Match the size to the surrounding row height — don't drop a `default` button into a 28px toolbar. + +### Other primitives in this repo + +Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI primitive — exceptions are `command` (wraps `cmdk`), `sonner` (wraps `sonner`), and the visual-only wrappers (`badge`, `button-group`, `card`, `input`) which apply tokens and Tailwind utilities directly. Never reimplement headless behavior; extend the existing wrapper. + +### Picking the right primitive + +When a control has multiple plausible primitives, use this fork: + +| You want… | Reach for | Don't use | +| -------------------------------------------------------- | -------------------------- | ---------------------------------------- | +| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr | +| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) | +| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list | +| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) | +| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) | +| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay | +| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | +| Single choice from a known list | `Select` | Custom listbox | +| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | +| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | +| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | +| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | + +If you find yourself styling around a primitive (`` to act like a ``, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch. + +### Tooltips + +Tooltips exist to *name* a control whose meaning isn't obvious from its appearance. They are not the place to teach, persuade, or warn — anything users need to read while acting belongs in the visible UI. + +- **Use a tooltip when:** an icon-only button or compact chip needs a label. Toolbar icons, badges with abbreviations, truncated paths. +- **Don't use a tooltip when:** the control already has a visible label, the content is interactive (links, buttons), or the message is critical (errors, blocking warnings — those go inline). +- **Mounting:** the global `` lives at the App root. Don't nest a second `TooltipProvider` unless you need a different delay for a tightly-scoped surface. +- **Trigger pattern:** wrap the trigger element with `` so the tooltip's accessibility props attach to the button (not a wrapper span). This is required for keyboard focus to surface the tooltip. +- **Placement:** default `side="top" sideOffset={4}` — match the toolbar pattern in `sidebar/SidebarToolbar.tsx`. Pick a different side only when the default would clip against the viewport. +- **Shortcut chips inside tooltips:** if the action has a keyboard shortcut, append `` rather than baking the keys into the label string. The chips render correctly per platform; baked-in strings drift. + +```tsx + + + + + Settings + +``` + +### Icons + +Icons come from **`lucide-react`**. Don't import a second icon library. + +- **Default size:** `size-4` (16px). `Button` auto-applies this to any `` it contains via `[&_svg:not([class*='size-'])]:size-4`, so most call sites don't need to set a size on the icon. +- **`size-3` / `size-3.5`:** for metadata, captions, and dense list rows where 16px is too loud. +- **`size-7`+:** for featured/empty-state hero icons only. +- **Stroke width:** lucide's default 2px. Don't override per-icon. +- **Color:** inherit from surrounding text — `text-muted-foreground` for secondary, `text-destructive` for destructive, etc. Don't apply a token to the SVG directly when the parent already carries the right color. +- **Spinner:** the canonical loading icon is ``. For 3s+ multi-step work, prefer a label that names the stage ("Cloning…" → "Installing…") over an unlabeled spinner. See *UX rule 1*. + +### Keyboard shortcut chips + +Use **``** from `src/renderer/src/components/ShortcutKeyCombo.tsx`. It renders a consistent key-cap style and inserts a `+` separator on Windows/Linux (Mac shows adjacent glyphs, no separator). It does **not** transform key strings — the *caller* picks the platform-appropriate labels and passes them in: + +```tsx +const isMac = navigator.userAgent.includes('Mac') +const mod = isMac ? '⌘' : 'Ctrl' +const shift = isMac ? '⇧' : 'Shift' + +``` + +See `Landing.tsx` for the canonical pattern. Don't roll a one-off `` — kbd chips drift in shape and color across the app fast if everyone styles their own. + +**Where shortcuts surface in the UI:** + +- **Tooltips on icon buttons** — append the chip after the label, trailing. +- **Dropdown / context-menu items** — use `` (or its context-menu equivalent) for the right-aligned chip; don't position one yourself. +- **Never on Cancel, Dismiss, or `link`-variant inline actions** — see *UX rule 3*. + +**The label MUST match the actual binding for the platform.** If the keyboard handler reads `metaKey` on Mac and `ctrlKey` elsewhere, the chip must show `⌘` on Mac and `Ctrl` elsewhere. Mismatched chips are worse than no chip. + +### Form anatomy + +The pattern in `src/renderer/src/components/settings/SettingsFormControls.tsx` is the house style for any label + control + helper text. Match it for new forms: + +- **Outer stack:** `space-y-3` for full-section forms (`ThemePicker`); `space-y-2` for compact single-control fields (`ColorField`, `NumberField`). Pick by density, not preference. +- **Label group:** `space-y-1` containing `
` that holds Inactive Pane Opacity + Divider Thickness. The new toggle is a full-width row (toggles don't balance well next to numeric fields in a 2-column grid). - -**Toggle shape** — mirrors the existing `role="switch"` pattern used by e.g. "Nest Workspaces" in `GeneralPane.tsx`: - -```tsx - -
- -

- Hovering a terminal pane activates it without needing to click. Mirrors Ghostty's - focus-follows-mouse setting. Selections and window switching stay safe. -

-
- -
-``` - -**Settings search integration** — add a new entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` in `src/renderer/src/components/settings/terminal-search.ts:34` (alongside "Inactive Pane Opacity" and "Divider Thickness"): - -```ts -{ - title: 'Focus Follows Mouse', - description: 'Hovering a terminal pane activates it without needing to click.', - keywords: ['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active'] -} -``` - -This surfaces the toggle in the settings search box for queries like "focus", "hover", or "ghostty". No changes needed to `TERMINAL_PANE_SEARCH_ENTRIES` (the aggregator) since it spreads `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` automatically. - -### Edge cases - -**Gate-covered (code enforces):** - -| Case | Gate | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Mouse button held mid-selection | `mouseButtons !== 0` | -| Mid-drag-to-reorder | `mouseButtons !== 0` (drag is always button-held) | -| Orca window unfocused (alt-tab) | `!document.hasFocus()` | -| DevTools panel focused | `!document.hasFocus()` — DevTools runs in a separate WebContents so the main document loses focus. Feature pauses until DevTools is closed or the pane is clicked. Acceptable — same behavior as any other focus loss. | -| Hover the already-active pane | `activePaneId === hoveredPaneId` | -| Manager destroyed mid-event | `managerDestroyed` | - -**Implicitly safe (relies on platform behavior, documented):** - -- **Context menus / close-confirm dialogs / command palette** — These render as React portals above the pane DOM. `mouseenter` does not reach the pane when an overlay is open. If a future contributor ever renders an overlay _inside_ the pane container, this assumption breaks — a code comment flags this. -- **Tab / worktree switching** — Hidden PaneManagers' DOM doesn't receive events. No cross-manager interference possible. -- **Single-pane layouts** — The `activePaneId === hoveredPaneId` early-return handles this with no special case. -- **Right-click** — `contextmenu` doesn't trigger `mouseenter` (no boundary crossing). Existing right-click behavior is preserved. - -**Intentionally accepted quirks:** - -- **Setting toggled ON while hovering a non-active pane** → feature doesn't kick in until next `mouseenter`. User must wiggle the mouse. The fix (track last hovered pane and replay on setting change) adds persistent state for a rare case. Not worth it. -- **Traversal flicker** (A → C via B on a three-pane layout) → briefly focuses B. Accepted as the cost of Ghostty-parity immediate switching. Confirmed during brainstorming. -- **Window-resize-under-stationary-mouse** → if pane boundaries cross the cursor during a resize, focus can shift to the newly-hovered pane even though the user didn't move the mouse. Arguably correct; adding a "suppress during resize" gate introduces fragile state. - -### Error handling - -The mouseenter handler does no I/O, no promises, no external state lookups. No error surface worth defending: - -- `document.hasFocus()` — synchronous boolean, cannot throw -- `event.buttons` — number, cannot throw -- `this.styleOptions.focusFollowsMouse ?? false` — missing settings fail safe (feature off) -- `setActivePane` — already handles pane-not-found silently at `pane-manager.ts:188` - -No try/catch added. If `setActivePane` throws, propagating to the window `error` handler is the correct failure mode — silently swallowing would hide a real bug. - -### Testing strategy - -**Constraint:** vitest runs with `environment: 'node'` (no jsdom). No synthetic event dispatch in automated tests. - -**1. Unit tests — `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`** - -Table of inputs against `shouldFollowMouseFocus`, one case per gate plus happy paths. Expected coverage: every branch. - -- Happy path: all gates pass → `true` -- Feature disabled → `false` -- Manager destroyed → `false` -- Hover the already-active pane → `false` -- Mouse button held (primary `buttons=1`, secondary `=2`, both `=3`) → `false` each -- Window lacks OS focus → `false` -- `activePaneId === null` (theoretically-unreachable defensive case — `createInitialPane` always sets `activePaneId` before mouse events are possible, but the gate logic must still behave correctly if this state ever occurs) → `true` - -**2. Manual smoke test checklist (PR description)** - -- [ ] Toggle persists across app restart. -- [ ] Multi-split: hovering an inactive pane activates it. -- [ ] Start text selection in pane A, drag into pane B. Selection extends normally; focus does NOT switch mid-drag. -- [ ] Drag a pane by its drag-handle (the top strip that appears on hover). Release on a drop zone. Focus/activation does NOT flicker during the drag. -- [ ] Cmd-Tab out, move mouse over a different Orca pane, Cmd-Tab back. Previously-active pane still active. -- [ ] Open DevTools, focus the DevTools panel, move mouse over a different Orca pane. Focus does NOT switch. Close DevTools, wiggle mouse → focus-follows-mouse resumes. -- [ ] Open close-terminal confirmation, hover a different pane. No focus shift. -- [ ] Hover a URL in an inactive pane. Focus-follows-mouse activates the pane. Verify the `Cmd+click to open` URL tooltip still appears correctly on the newly-activated pane (tests for interaction between our `setActivePane → terminal.focus()` call and xterm.js's `WebLinksAddon` hover tracking). -- [ ] Disable the setting. Hover does nothing; click still works. -- [ ] Single-pane layout with the setting enabled. No crash, pane stays focused. -- [ ] Three-pane layout, quick A→C sweep. Traversal flicker visible but tolerable. - -**Explicitly not tested:** - -- DOM event dispatch integration (no jsdom, not worth adding) -- Settings UI render (no precedent in the codebase for per-control UI tests) -- Electron's `document.hasFocus()` semantics (trust the platform) - -## Files Touched - -| File | Change | -| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | -| `src/shared/types.ts` | Add `terminalFocusFollowsMouse: boolean` to `GlobalSettings` | -| `src/shared/constants.ts` | Default `terminalFocusFollowsMouse: false` in `getDefaultSettings` | -| `src/renderer/src/lib/pane-manager/pane-manager-types.ts` | Add `focusFollowsMouse?: boolean` to `PaneStyleOptions` with a commented rationale | -| `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` | **New file.** Pure `shouldFollowMouseFocus` gate helper | -| `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` | **New file.** Unit tests for the gate helper | -| `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` | Add `onMouseEnter` param to `createPaneDOM`; attach `mouseenter` listener on pane container | -| `src/renderer/src/lib/pane-manager/pane-manager.ts` | Pass new callback from `createPaneInternal`; add `handlePaneMouseEnter` private method | -| `src/renderer/src/components/settings/TerminalPane.tsx` | Add `SearchableSetting` toggle under Pane Styling | -| `src/renderer/src/components/settings/terminal-search.ts` | Add entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` | -| `src/renderer/src/lib/terminal-theme.ts` | Extend `resolvePaneStyleOptions` to pass `focusFollowsMouse` through | -| `src/renderer/src/components/terminal-pane/terminal-appearance.ts` | Add `focusFollowsMouse: paneStyles.focusFollowsMouse` to the `setPaneStyleOptions` call at line 48 | - -## Build Order - -1. Add `terminalFocusFollowsMouse` to `GlobalSettings` type and default. Build passes; no behavior change. -2. Add `focusFollowsMouse` to `PaneStyleOptions`. Build passes; no behavior change. -3. Create `focus-follows-mouse.ts` pure helper + `focus-follows-mouse.test.ts`. Tests pass. -4. Update `createPaneDOM` signature and wire `mouseenter` listener. -5. Add `handlePaneMouseEnter` in `PaneManager` and the new callback in `createPaneInternal`. -6. Thread `terminalFocusFollowsMouse → focusFollowsMouse` through `resolvePaneStyleOptions` and `applyTerminalAppearance`. -7. Add the settings toggle UI in `TerminalPane.tsx` and the search entry in `terminal-search.ts`. -8. Run the manual smoke test checklist against a local build. - -## Risks & Open Questions - -**Risk: traversal flicker on three-pane layouts may feel worse than expected.** Mitigation: the user explicitly chose immediate switching for Ghostty parity. If complaints arrive, adding a `terminalFocusFollowsMouseDelayMs` setting is a clean follow-up without schema breakage. - -**Risk: overlay assumption could break if a future contributor renders a modal inside the pane container.** Mitigation: a code comment on `handlePaneMouseEnter` documents the "overlays must live outside the pane container" invariant so it surfaces during review of any future overlay changes. - -**Open: should the setting description link to Ghostty's docs?** Currently the description just says "Mirrors Ghostty's focus-follows-mouse setting." No external link. If Orca's settings UI supports hyperlinks in descriptions elsewhere, we could link to Ghostty's config docs — otherwise leave as plain text. Not a blocker. diff --git a/docs/focus-follows-mouse-plan.md b/docs/focus-follows-mouse-plan.md deleted file mode 100644 index 36ad9519f74..00000000000 --- a/docs/focus-follows-mouse-plan.md +++ /dev/null @@ -1,837 +0,0 @@ -# Focus-Follows-Mouse Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a Ghostty-style `focus-follows-mouse` behavior to Orca's terminal panes. When enabled, hovering a split activates it (cursor focus, input routing, opacity update) without a click. - -**Architecture:** Extend `PaneStyleOptions` with a `focusFollowsMouse?: boolean` flag. Attach a `mouseenter` listener on each pane container in `createPaneDOM`. Delegate all gating logic to a pure `shouldFollowMouseFocus` helper (no DOM, unit-testable) that checks five gates: feature enabled, manager not destroyed, not already active, no mouse button held, window has OS focus. Thread the setting through the existing `resolvePaneStyleOptions → applyTerminalAppearance → setPaneStyleOptions` pipeline. - -**Tech Stack:** TypeScript, Electron (renderer), React (settings UI), xterm.js (terminal), vanilla DOM (PaneManager), vitest (tests, node environment). - -**Spec:** [docs/focus-follows-mouse-design.md](./focus-follows-mouse-design.md) — read this first for design rationale, edge case reasoning, and the full manual smoke test checklist. - ---- - -## File Structure - -**New files (2):** - -- `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` — Pure gate helper. Exports `FocusFollowsMouseInput` type and `shouldFollowMouseFocus(input)` function. Zero DOM dependencies. -- `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` — Unit tests for the pure helper. Table-style assertions, one case per gate. - -**Modified files (9):** - -- `src/shared/types.ts` — Add `terminalFocusFollowsMouse: boolean` field to `GlobalSettings` type. -- `src/shared/constants.ts` — Add `terminalFocusFollowsMouse: false` default in `getDefaultSettings`. -- `src/renderer/src/lib/pane-manager/pane-manager-types.ts` — Add `focusFollowsMouse?: boolean` to `PaneStyleOptions`. -- `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` — Add `onMouseEnter` parameter to `createPaneDOM`, attach a `mouseenter` listener on the pane container. -- `src/renderer/src/lib/pane-manager/pane-manager.ts` — Add `handlePaneMouseEnter` private method. Pass the new callback from `createPaneInternal`. -- `src/renderer/src/lib/terminal-theme.ts` — Extend `resolvePaneStyleOptions` to pass `focusFollowsMouse` through. -- `src/renderer/src/components/terminal-pane/terminal-appearance.ts` — Add `focusFollowsMouse: paneStyles.focusFollowsMouse` to the `setPaneStyleOptions` call. -- `src/renderer/src/components/settings/TerminalPane.tsx` — Add a `SearchableSetting` toggle under Pane Styling. -- `src/renderer/src/components/settings/terminal-search.ts` — Add an entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES`. - -Each task below produces a standalone, type-checking commit. The sequence is chosen so the build stays green at every step. - ---- - -## Task 1: Add `terminalFocusFollowsMouse` to GlobalSettings - -**Files:** - -- Modify: `src/shared/types.ts` (insert field in `GlobalSettings` type around line 311) -- Modify: `src/shared/constants.ts` (insert default in `getDefaultSettings` around line 83) - -Because `GlobalSettings` is an exact type, both the type declaration and the `getDefaultSettings` initializer must be updated together or `tsc` fails. Combine both edits in one commit. - -- [ ] **Step 1: Add the field to the GlobalSettings type** - -Edit `src/shared/types.ts`. Find the line with `terminalDividerThicknessPx: number` (near line 311) and add the new field immediately after it: - -```ts -terminalDividerThicknessPx: number -terminalFocusFollowsMouse: boolean -terminalScrollbackBytes: number -``` - -- [ ] **Step 2: Add the default value in getDefaultSettings** - -Edit `src/shared/constants.ts`. Find the line `terminalDividerThicknessPx: 3,` in `getDefaultSettings` (near line 83) and add the default immediately after it: - -```ts - terminalDividerThicknessPx: 3, - terminalFocusFollowsMouse: false, - terminalScrollbackBytes: 10_000_000, -``` - -**Why default `false`:** Matches Ghostty's default. Existing users upgrading receive this default automatically because `src/main/persistence.ts:73-75` merges persisted settings over `defaults.settings` (`{ ...defaults.settings, ...parsed.settings }`), so any new field not in the persisted JSON falls through to the default. No explicit migration needed. - -- [ ] **Step 3: Run typecheck to verify both edits** - -Run: `pnpm run tc:web && pnpm run tc:node` -Expected: Both pass. No output beyond the tsgo banner. - -- [ ] **Step 4: Commit** - -```bash -git add src/shared/types.ts src/shared/constants.ts -git commit -m "feat: add terminalFocusFollowsMouse to GlobalSettings (default off)" -``` - ---- - -## Task 2: Extend `PaneStyleOptions` with `focusFollowsMouse` - -**Files:** - -- Modify: `src/renderer/src/lib/pane-manager/pane-manager-types.ts` (line 23-30, the `PaneStyleOptions` type) - -- [ ] **Step 1: Add the field with a commented rationale** - -Edit `src/renderer/src/lib/pane-manager/pane-manager-types.ts`. Replace the existing `PaneStyleOptions` type definition (lines 23-30) with: - -```ts -export type PaneStyleOptions = { - splitBackground?: string - paneBackground?: string - inactivePaneOpacity?: number - activePaneOpacity?: number - opacityTransitionMs?: number - dividerThicknessPx?: number - // Why this behavior flag lives on "style" options: this type is already - // the single runtime-settings bag the PaneManager exposes. Splitting into - // separate style vs behavior types is a refactor worth its own change - // when a second behavior flag lands. See docs/focus-follows-mouse-design.md. - focusFollowsMouse?: boolean -} -``` - -- [ ] **Step 2: Run typecheck** - -Run: `pnpm run tc:web` -Expected: Pass. - -- [ ] **Step 3: Commit** - -```bash -git add src/renderer/src/lib/pane-manager/pane-manager-types.ts -git commit -m "refactor: add focusFollowsMouse to PaneStyleOptions type" -``` - ---- - -## Task 3: Create the pure `shouldFollowMouseFocus` gate helper (TDD) - -**Files:** - -- Create: `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` -- Create: `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` - -This is the only part of the feature with isolated business logic, so it's the only part we can TDD. We write the test file first, run it to see it fail, then create the implementation to make it pass. - -- [ ] **Step 1: Write the failing test file** - -Create `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` with the following contents: - -```ts -import { describe, expect, it } from 'vitest' -import { shouldFollowMouseFocus, type FocusFollowsMouseInput } from './focus-follows-mouse' - -describe('shouldFollowMouseFocus', () => { - // Base input where every gate passes. Individual tests flip one field - // at a time to assert that each gate blocks focus independently. - const base: FocusFollowsMouseInput = { - featureEnabled: true, - activePaneId: 1, - hoveredPaneId: 2, - mouseButtons: 0, - windowHasFocus: true, - managerDestroyed: false - } - - it('switches focus when all gates pass', () => { - expect(shouldFollowMouseFocus(base)).toBe(true) - }) - - it('blocks when the feature is disabled', () => { - expect(shouldFollowMouseFocus({ ...base, featureEnabled: false })).toBe(false) - }) - - it('blocks when the manager is destroyed', () => { - expect(shouldFollowMouseFocus({ ...base, managerDestroyed: true })).toBe(false) - }) - - it('blocks when hovering the already-active pane', () => { - expect(shouldFollowMouseFocus({ ...base, hoveredPaneId: 1 })).toBe(false) - }) - - it('blocks while the primary mouse button is held (buttons=1)', () => { - expect(shouldFollowMouseFocus({ ...base, mouseButtons: 1 })).toBe(false) - }) - - it('blocks while the secondary mouse button is held (buttons=2)', () => { - expect(shouldFollowMouseFocus({ ...base, mouseButtons: 2 })).toBe(false) - }) - - it('blocks while multiple buttons are held (buttons=3)', () => { - expect(shouldFollowMouseFocus({ ...base, mouseButtons: 3 })).toBe(false) - }) - - it('blocks when the window does not have OS focus', () => { - expect(shouldFollowMouseFocus({ ...base, windowHasFocus: false })).toBe(false) - }) - - // Defensive case: createInitialPane always sets activePaneId before any - // mouse events are possible in production, but the gate must still behave - // correctly if the state ever occurs (e.g. future refactor of init flow). - it('switches when activePaneId is null (defensive)', () => { - expect(shouldFollowMouseFocus({ ...base, activePaneId: null })).toBe(true) - }) -}) -``` - -- [ ] **Step 2: Run the test file to confirm it fails** - -Run: `pnpm exec vitest run src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` -Expected: FAIL with an error resolving `./focus-follows-mouse` (the implementation file doesn't exist yet). - -- [ ] **Step 3: Create the implementation file** - -Create `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` with the following contents: - -```ts -/** - * Pure decision logic for the focus-follows-mouse feature. Kept free of - * DOM/event dependencies so it can be unit-tested under vitest's node env. - * - * See docs/focus-follows-mouse-design.md for rationale behind each gate. - */ - -export type FocusFollowsMouseInput = { - featureEnabled: boolean - activePaneId: number | null - hoveredPaneId: number - mouseButtons: number // MouseEvent.buttons bitmask - windowHasFocus: boolean // document.hasFocus() - managerDestroyed: boolean -} - -/** Returns true iff the hovered pane should be activated. */ -export function shouldFollowMouseFocus(input: FocusFollowsMouseInput): boolean { - if (!input.featureEnabled) return false - if (input.managerDestroyed) return false - if (input.activePaneId === input.hoveredPaneId) return false - // Why event.buttons !== 0: any held mouse button means a selection or - // a drag is in progress. Switching focus mid-drag would break xterm.js - // text selection and the pane drag-to-reorder flow. This single check - // also covers drag-to-reorder, since the drag is always button-held. - // See pane-drag-reorder.ts:77-103 for the drag state lifecycle. - if (input.mouseButtons !== 0) return false - // Why document.hasFocus: if Orca isn't the OS-focused window, the mouse - // event is from the user passing through on their way to another app. - // Also returns false when DevTools is focused (DevTools runs in a - // separate WebContents) — accepted. Users close DevTools or click to - // resume normal behavior. - if (!input.windowHasFocus) return false - return true -} -``` - -- [ ] **Step 4: Run the test file again to confirm all tests pass** - -Run: `pnpm exec vitest run src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` -Expected: PASS — 9 tests passing, 0 failing. - -- [ ] **Step 5: Run the full test suite to confirm nothing else broke** - -Run: `pnpm run test` -Expected: All pre-existing tests still pass, plus the 9 new ones. - -- [ ] **Step 6: Commit** - -```bash -git add src/renderer/src/lib/pane-manager/focus-follows-mouse.ts src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts -git commit -m "feat: add pure shouldFollowMouseFocus gate helper" -``` - ---- - -## Task 4: Wire the `mouseenter` listener through `createPaneDOM` and `PaneManager` - -**Files:** - -- Modify: `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` (update `createPaneDOM` signature around line 23-29, attach listener around line 126) -- Modify: `src/renderer/src/lib/pane-manager/pane-manager.ts` (add import around line 28, update `createPaneInternal` around line 287, add `handlePaneMouseEnter` after it) - -Both files must be updated in the same commit: `createPaneDOM` gains a required parameter that its sole caller (`createPaneInternal`) must provide, so partial landings break the build. - -- [ ] **Step 1: Add the `onMouseEnter` parameter to `createPaneDOM`** - -Edit `src/renderer/src/lib/pane-manager/pane-lifecycle.ts`. Find the `createPaneDOM` function signature (around line 23-29) and update it: - -```ts -export function createPaneDOM( - id: number, - options: PaneManagerOptions, - dragState: DragReorderState, - dragCallbacks: DragReorderCallbacks, - onPointerDown: (id: number) => void, - onMouseEnter: (id: number, event: MouseEvent) => void -): ManagedPaneInternal { -``` - -- [ ] **Step 2: Attach the `mouseenter` listener next to the existing `pointerdown` listener** - -In the same file, find the existing `pointerdown` listener (around line 126): - -```ts -// Focus handler: clicking a pane makes it active and explicitly focuses -// the terminal. We must call focus: true here because after DOM reparenting -// (e.g. splitPane moves the original pane into a flex container), xterm.js's -// native click-to-focus on its internal textarea may not fire reliably. -container.addEventListener('pointerdown', () => { - onPointerDown(id) -}) - -return pane -``` - -Replace it with: - -```ts -// Focus handler: clicking a pane makes it active and explicitly focuses -// the terminal. We must call focus: true here because after DOM reparenting -// (e.g. splitPane moves the original pane into a flex container), xterm.js's -// native click-to-focus on its internal textarea may not fire reliably. -container.addEventListener('pointerdown', () => { - onPointerDown(id) -}) - -// Focus-follows-mouse handler: when the setting is enabled, hovering a -// pane makes it active. All gating (feature flag, drag-in-progress, -// window focus, etc.) lives in the PaneManager callback — this layer -// just forwards the event. -container.addEventListener('mouseenter', (event) => { - onMouseEnter(id, event) -}) - -return pane -``` - -- [ ] **Step 3: Import the gate helper in `pane-manager.ts`** - -Edit `src/renderer/src/lib/pane-manager/pane-manager.ts`. Find the existing imports (around line 20-28) and add a new import immediately after the existing pane-manager-related imports: - -```ts -import { createPaneDOM, openTerminal, attachWebgl, disposePane } from './pane-lifecycle' -import { shouldFollowMouseFocus } from './focus-follows-mouse' -import { - findPaneChildren, - removeDividers, - promoteSibling, - wrapInSplit, - safeFit, - refitPanesUnder -} from './pane-tree-ops' -``` - -- [ ] **Step 4: Pass the new callback from `createPaneInternal`** - -In the same file, find the `createPaneInternal` method (around line 287-302): - -```ts - private createPaneInternal(): ManagedPaneInternal { - const id = this.nextPaneId++ - const pane = createPaneDOM( - id, - this.options, - this.dragState, - this.getDragCallbacks(), - (paneId) => { - if (!this.destroyed && this.activePaneId !== paneId) { - this.setActivePane(paneId, { focus: true }) - } - } - ) - this.panes.set(id, pane) - return pane - } -``` - -Replace it with: - -```ts - private createPaneInternal(): ManagedPaneInternal { - const id = this.nextPaneId++ - const pane = createPaneDOM( - id, - this.options, - this.dragState, - this.getDragCallbacks(), - (paneId) => { - if (!this.destroyed && this.activePaneId !== paneId) { - this.setActivePane(paneId, { focus: true }) - } - }, - (paneId, event) => { - this.handlePaneMouseEnter(paneId, event) - } - ) - this.panes.set(id, pane) - return pane - } - - /** - * Focus-follows-mouse entry point. Collects gate inputs from the manager - * and delegates to the pure gate helper. - * - * Invariant for future contributors: modal overlays (context menus, close - * dialogs, command palette) must be rendered as portals/siblings OUTSIDE - * the pane container. If a future overlay is ever rendered inside a .pane - * element, mouseenter will still fire on the pane underneath and this - * handler will incorrectly switch focus. Keep overlays out of the pane. - */ - private handlePaneMouseEnter(paneId: number, event: MouseEvent): void { - if ( - shouldFollowMouseFocus({ - featureEnabled: this.styleOptions.focusFollowsMouse ?? false, - activePaneId: this.activePaneId, - hoveredPaneId: paneId, - mouseButtons: event.buttons, - windowHasFocus: document.hasFocus(), - managerDestroyed: this.destroyed - }) - ) { - this.setActivePane(paneId, { focus: true }) - } - } -``` - -- [ ] **Step 5: Run typecheck to verify both files** - -Run: `pnpm run tc:web` -Expected: Pass. If it fails with "Expected 6 arguments, but got 5" at `createPaneDOM`, you forgot to update the caller in `createPaneInternal`. - -- [ ] **Step 6: Run the full test suite** - -Run: `pnpm run test` -Expected: All tests pass, including the 9 from Task 3. - -- [ ] **Step 7: Commit** - -```bash -git add src/renderer/src/lib/pane-manager/pane-lifecycle.ts src/renderer/src/lib/pane-manager/pane-manager.ts -git commit -m "feat: wire mouseenter listener through PaneManager for focus-follows-mouse" -``` - ---- - -## Task 5: Thread the setting through `resolvePaneStyleOptions` and `applyTerminalAppearance` - -**Files:** - -- Modify: `src/renderer/src/lib/terminal-theme.ts` (around line 103-120) -- Modify: `src/renderer/src/components/terminal-pane/terminal-appearance.ts` (around line 48-55) - -At this point the gate helper reads `this.styleOptions.focusFollowsMouse`, which is always `undefined` because nothing populates it yet. This task plumbs the user setting into the runtime options bag. - -- [ ] **Step 1: Extend `resolvePaneStyleOptions` to accept and pass the boolean** - -**Callers of `resolvePaneStyleOptions`** (verified via grep — there are two, both pass a full `GlobalSettings` so widening the `Pick` union is non-breaking): - -1. `src/renderer/src/components/terminal-pane/terminal-appearance.ts:21` — the runtime call from `applyTerminalAppearance`. You WILL modify this file in Step 2. -2. `src/renderer/src/components/settings/TerminalPane.tsx:68` — the settings-pane preview call that powers the theme preview. You will NOT need to modify this file in this task (it passes full `GlobalSettings`, which will contain `terminalFocusFollowsMouse` after Task 1). The new field will silently appear in the returned object and be ignored by the preview — that's fine. - -Edit `src/renderer/src/lib/terminal-theme.ts`. Find the `resolvePaneStyleOptions` function (lines 103-118). The current full body is: - -```ts -export function resolvePaneStyleOptions( - settings: Pick< - GlobalSettings, - | 'terminalInactivePaneOpacity' - | 'terminalActivePaneOpacity' - | 'terminalPaneOpacityTransitionMs' - | 'terminalDividerThicknessPx' - > -) { - return { - inactivePaneOpacity: clampNumber(settings.terminalInactivePaneOpacity, 0, 1), - activePaneOpacity: clampNumber(settings.terminalActivePaneOpacity, 0, 1), - opacityTransitionMs: clampNumber(settings.terminalPaneOpacityTransitionMs, 0, 5000), - dividerThicknessPx: clampNumber(settings.terminalDividerThicknessPx, 1, 32) - } -} -``` - -Replace it with: - -```ts -export function resolvePaneStyleOptions( - settings: Pick< - GlobalSettings, - | 'terminalInactivePaneOpacity' - | 'terminalActivePaneOpacity' - | 'terminalPaneOpacityTransitionMs' - | 'terminalDividerThicknessPx' - | 'terminalFocusFollowsMouse' - > -) { - return { - inactivePaneOpacity: clampNumber(settings.terminalInactivePaneOpacity, 0, 1), - activePaneOpacity: clampNumber(settings.terminalActivePaneOpacity, 0, 1), - opacityTransitionMs: clampNumber(settings.terminalPaneOpacityTransitionMs, 0, 5000), - dividerThicknessPx: clampNumber(settings.terminalDividerThicknessPx, 1, 32), - // Why no clamping: boolean pass-through. Both true and false are valid. - focusFollowsMouse: settings.terminalFocusFollowsMouse - } -} -``` - -- [ ] **Step 2: Pass `focusFollowsMouse` to `setPaneStyleOptions` in `applyTerminalAppearance`** - -Edit `src/renderer/src/components/terminal-pane/terminal-appearance.ts`. Find the `setPaneStyleOptions` call (around line 48-55): - -```ts -manager.setPaneStyleOptions({ - splitBackground: paneBackground, - paneBackground, - inactivePaneOpacity: paneStyles.inactivePaneOpacity, - activePaneOpacity: paneStyles.activePaneOpacity, - opacityTransitionMs: paneStyles.opacityTransitionMs, - dividerThicknessPx: paneStyles.dividerThicknessPx -}) -``` - -Replace it with: - -```ts -manager.setPaneStyleOptions({ - splitBackground: paneBackground, - paneBackground, - inactivePaneOpacity: paneStyles.inactivePaneOpacity, - activePaneOpacity: paneStyles.activePaneOpacity, - opacityTransitionMs: paneStyles.opacityTransitionMs, - dividerThicknessPx: paneStyles.dividerThicknessPx, - focusFollowsMouse: paneStyles.focusFollowsMouse -}) -``` - -- [ ] **Step 3: Run typecheck** - -Run: `pnpm run tc:web` -Expected: Pass. If it fails saying `paneStyles.focusFollowsMouse` is not assignable (e.g. `undefined` vs `boolean`), double-check that Step 1 added the field to `resolvePaneStyleOptions`'s returned object. - -- [ ] **Step 4: Run the full test suite** - -Run: `pnpm run test` -Expected: All tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/renderer/src/lib/terminal-theme.ts src/renderer/src/components/terminal-pane/terminal-appearance.ts -git commit -m "feat: thread focusFollowsMouse through pane-style pipeline" -``` - -**At this point the feature is functionally complete internally.** If a developer manually flipped `terminalFocusFollowsMouse: true` in the persisted settings file, it would work. Tasks 6 and 7 add the discoverable user-facing surface. - ---- - -## Task 6: Add the settings toggle UI in TerminalPane.tsx - -**Files:** - -- Modify: `src/renderer/src/components/settings/TerminalPane.tsx` (disable comment at line 1; toggle insertion around line 290, the closing of the Pane Styling grid) - -**⚠️ Line-count constraint:** `TerminalPane.tsx` is currently exactly 400 lines total (and oxlint passes, so effective non-blank/non-comment lines are ≤ 400). The project's `.oxlintrc.json` sets `max-lines: 400` for `.tsx` files (line 71). Adding the new toggle (~33 lines) would push the file over the limit and the commit in Step 3 would fail under the lint-staged `oxlint` pre-commit hook. - -**The project's established workaround** is a file-scoped `eslint-disable` comment with justification — see `src/renderer/src/components/settings/GeneralPane.tsx:1-3`, which does the same for the same reason (484 lines, all owning general settings UI). Step 1 below applies this pattern to `TerminalPane.tsx` _before_ adding the toggle, so the file never goes over the limit. - -- [ ] **Step 1: Add the `max-lines` eslint-disable at the top of TerminalPane.tsx** - -Edit `src/renderer/src/components/settings/TerminalPane.tsx`. The file currently starts with: - -```tsx -import { useState } from 'react' -import type { GlobalSettings } from '../../../../shared/types' -``` - -Prepend a file-level disable comment with an explicit justification (matching the `GeneralPane.tsx` precedent): - -```tsx -/* eslint-disable max-lines -- Why: TerminalPane is the single owner of all terminal settings UI; - splitting individual settings into separate files would scatter related controls without a - meaningful abstraction boundary. Mirrors the same decision made for GeneralPane.tsx. */ -import { useState } from 'react' -import type { GlobalSettings } from '../../../../shared/types' -``` - -- [ ] **Step 2: Add the `SearchableSetting` toggle below the Pane Styling grid** - -Edit `src/renderer/src/components/settings/TerminalPane.tsx`. Find the end of the Pane Styling grid (around line 290): - -```tsx - - - updateSettings({ - terminalDividerThicknessPx: clampNumber(value, 1, 32) - }) - } - /> - -
- -``` - -Insert a new `SearchableSetting` toggle between the closing `` of the grid and the closing ``: - -```tsx - - - updateSettings({ - terminalDividerThicknessPx: clampNumber(value, 1, 32) - }) - } - /> - - - - -
- -

- Hovering a terminal pane activates it without needing to click. - Mirrors Ghostty's focus-follows-mouse setting. Selections and - window switching stay safe. -

-
- -
- -``` - -**Placement rationale:** The new toggle sits _below_ the existing 2-column `grid` (not inside it) because a full-width toggle row doesn't balance well next to numeric input fields in a 2-column layout. - -- [ ] **Step 3: Run typecheck and lint** - -Run: `pnpm run tc:web && pnpm exec oxlint src/renderer/src/components/settings/TerminalPane.tsx` -Expected: Both pass with 0 errors. The explicit `oxlint` run is a safety net: if the file somehow still trips `max-lines` (e.g., the disable comment was formatted incorrectly), catch it here rather than in the pre-commit hook. - -- [ ] **Step 4: Commit** - -```bash -git add src/renderer/src/components/settings/TerminalPane.tsx -git commit -m "feat: add Focus Follows Mouse toggle to Terminal settings" -``` - ---- - -## Task 7: Add the settings search entry - -**Files:** - -- Modify: `src/renderer/src/components/settings/terminal-search.ts` (around line 34-45, the `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` array) - -- [ ] **Step 1: Add the new entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES`** - -Edit `src/renderer/src/components/settings/terminal-search.ts`. Find `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` (around line 34): - -```ts -export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Inactive Pane Opacity', - description: 'Opacity applied to panes that are not currently active.', - keywords: ['pane', 'opacity', 'dimming'] - }, - { - title: 'Divider Thickness', - description: 'Thickness of the pane divider line.', - keywords: ['pane', 'divider', 'thickness'] - } -] -``` - -Replace it with: - -```ts -export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Inactive Pane Opacity', - description: 'Opacity applied to panes that are not currently active.', - keywords: ['pane', 'opacity', 'dimming'] - }, - { - title: 'Divider Thickness', - description: 'Thickness of the pane divider line.', - keywords: ['pane', 'divider', 'thickness'] - }, - { - title: 'Focus Follows Mouse', - description: 'Hovering a terminal pane activates it without needing to click.', - keywords: ['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active'] - } -] -``` - -**Note:** No changes needed to `TERMINAL_PANE_SEARCH_ENTRIES` (the aggregator at line 86) because it spreads `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` automatically. - -- [ ] **Step 2: Run typecheck and tests** - -Run: `pnpm run tc:web && pnpm run test` -Expected: Both pass. - -- [ ] **Step 3: Commit** - -```bash -git add src/renderer/src/components/settings/terminal-search.ts -git commit -m "feat: add Focus Follows Mouse to settings search index" -``` - ---- - -## Task 8: Manual smoke test verification - -This task does not produce a commit. It verifies the feature end-to-end in a running build and catches any issue that couldn't be caught by typecheck or unit tests (DOM event interactions, xterm.js behavior, Electron window focus, visual correctness). - -- [ ] **Step 1: Launch the app in dev mode** - -Run: `pnpm run dev` -Expected: Orca opens. Wait for it to fully initialize (worktree list populates). - -- [ ] **Step 2: Enable the setting** - -Open Settings (gear icon or menu) → Terminal → Pane Styling. Find the "Focus Follows Mouse" toggle. Toggle it ON. - -Expected: The toggle visibly flips to the on state (dark background, white dot on the right). - -- [ ] **Step 3: Restart the app and verify persistence** - -Close Orca. Run `pnpm run dev` again. Open Settings → Terminal → Pane Styling. - -Expected: The toggle is still ON (the setting persisted to the JSON state file). - -- [ ] **Step 4: Verify multi-split hover activation** - -Open a worktree with a terminal. Create at least 2 splits (use the split-pane UI or keyboard shortcut). Click on pane A to make it active. Move the mouse over pane B without clicking. - -Expected: Pane B's opacity updates to the active value, pane B's cursor starts blinking, and typing on the keyboard routes to pane B's shell. - -- [ ] **Step 5: Verify text selection is not broken mid-drag** - -In a single pane, click-drag to select text. Start the drag in pane A and continue dragging the selection into pane B. - -Expected: The text selection extends normally. Focus does NOT switch from pane A to pane B during the drag. When you release the mouse button, the selection is finalized in pane A. (If focus switches mid-drag, the `mouseButtons !== 0` gate is broken.) - -- [ ] **Step 6: Verify pane drag-to-reorder is not broken** - -Hover a pane so the drag handle (the top strip) appears. Click-drag the pane by its drag handle to a drop zone. - -Expected: The drag animation completes normally. No focus flicker during the drag. - -- [ ] **Step 7: Verify window-focus gating with Cmd-Tab** - -With 2+ splits and the setting enabled: click pane A to make it active. Cmd-Tab to another app. Move the mouse over pane B (still visible). Cmd-Tab back to Orca. - -Expected: Pane A (the previously-active pane) is still active — focus did NOT switch to pane B just because the mouse was over it when Orca regained focus. - -- [ ] **Step 8: Verify DevTools focus pauses the feature** - -With 2+ splits, open Orca DevTools (View → Toggle Developer Tools, or Cmd-Opt-I). Click inside the DevTools panel to focus it. Move the mouse over an inactive pane. - -Expected: Focus does NOT switch. `document.hasFocus()` returns false while DevTools is focused, so the gate blocks. Close DevTools, wiggle the mouse → focus-follows-mouse resumes on the next `mouseenter`. - -- [ ] **Step 9: Verify modal overlay safety** - -With 2+ splits, open the close-terminal confirmation dialog (Cmd-W or the close button on a pane). While the confirmation dialog is visible, move the mouse over a different pane. - -Expected: Focus does NOT switch. The modal portal sits above the pane DOM, so `mouseenter` on the pane never fires while the modal is open. - -- [ ] **Step 10: Verify URL hover tooltip still works** - -With the setting ON, hover a URL in an inactive pane (run `echo https://example.com` in one pane, then hover that URL from another pane). - -Expected: (1) The pane activates via focus-follows-mouse. (2) The `Cmd+click to open` URL tooltip appears in the bottom-left of the newly-activated pane. (If the tooltip fails to appear or flickers, there's an interaction issue between `setActivePane → terminal.focus()` and xterm.js's `WebLinksAddon` hover tracking — worth filing and debugging separately.) - -- [ ] **Step 11: Verify disabling the setting stops the behavior** - -Toggle the setting OFF in Settings. Return to the terminal view. Hover an inactive pane. - -Expected: Focus does NOT switch on hover. Click on the pane — the click still activates it. (Disabling the feature must not regress click-to-focus.) - -- [ ] **Step 12: Verify single-pane layout has no regressions** - -Close all splits so only one pane remains. Toggle the setting ON. Hover the pane. - -Expected: No crash, no console errors. The single pane stays focused. (The `activePaneId === hoveredPaneId` gate makes this a no-op.) - -- [ ] **Step 13: Verify three-pane traversal flicker (informational)** - -Create a horizontal layout of 3 panes: A | B | C. Click pane A. Move the mouse quickly from A straight to C. - -Expected: A brief flicker of activation on B as the mouse traverses it (this is the accepted cost of Ghostty-parity immediate switching, documented in the spec). Confirm the flicker is tolerable. If it feels bad, the spec has a follow-up plan for adding a settle delay. - -- [ ] **Step 14: Mark the plan complete** - -If all 13 checks above passed, the feature is complete. The PR description should embed this smoke test checklist for the reviewer to verify. - ---- - -## Verification Summary - -After all 8 tasks are complete, the final state should be: - -- 7 new commits on the branch (one per task, Task 8 is verify-only). -- 9 new unit tests passing (`focus-follows-mouse.test.ts`). -- Full typecheck passes (`pnpm run tc`). -- Full test suite passes (`pnpm run test`). -- Manual smoke test checklist (Task 8 Steps 1-13) passes in `pnpm run dev`. -- The setting is discoverable via Settings → Terminal → Pane Styling AND via the settings search box (queries: "focus", "follows", "mouse", "hover", "ghostty"). -- The setting defaults to **off** for both new and upgrading users. diff --git a/docs/gh-work-item-drawer-cache-flash.md b/docs/gh-work-item-drawer-cache-flash.md new file mode 100644 index 00000000000..9d8a0cf4735 --- /dev/null +++ b/docs/gh-work-item-drawer-cache-flash.md @@ -0,0 +1,132 @@ +# GitHub Work Item Drawer: Fix Reopen Flash + +## Symptom + +After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears. + +## Verified root cause + +`GitHubItemDialog` currently paints from component state (`details`, `loading`, `error`) that is updated in an effect (`src/renderer/src/components/GitHubItemDialog.tsx:2480+`). + +Current sequence on close/reopen of the same item: + +1. Close path runs effect branch with no `workItem` and calls `setDetails(null)`. +2. Reopen renders once with `details === null`. +3. Effect reads `workItemDetailsCache` and then calls `setDetails(painted)`. +4. Second render shows cached content. + +With sheet animation, that first empty paint is visible as a flash. + +## Refactor direction + +Move drawer paint state to the module cache and subscribe with `useSyncExternalStore`. The drawer should read the cache entry synchronously during render. + +This is not optional if we want reopen to paint cached content on first render. + +## Required cache-store changes + +Add a store subscription layer around `workItemDetailsCache`: + +- `subscribeWorkItemDetailsCache(listener)` +- `notifyWorkItemDetailsCache()` + +Call `notifyWorkItemDetailsCache()` after every actual cache mutation: + +- `touchWorkItemDetailsCache` +- `invalidateWorkItemDetailsCacheForKey` +- `invalidateWorkItemDetailsCacheByMatch` + +Also increment `workItemDetailsCacheGeneration` on **all** invalidations, not only `invalidateWorkItemDetailsCacheByMatch`. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data. + +## `useSyncExternalStore` contract details + +Use: + +```ts +const cachedEntry = useSyncExternalStore( + subscribeWorkItemDetailsCache, + () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined) +) +``` + +Important constraints: + +- `getSnapshot` must return a referentially stable value when store data has not changed. +- Returning the map entry object is valid **only if** writes replace entry identity (`delete+set` with a new entry object). +- Do not build fresh wrapper objects in `getSnapshot` (that would violate stability and can cause render loops). + +## Component state changes + +Remove component-local `details/loading/error` state for work-item details. + +Derive view state from `cachedEntry`: + +- `details = cachedEntry?.details ?? null` +- blocking `loading = !!cachedEntry?.pending && !cachedEntry?.details` +- blocking `error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null` + +Keep stale-on-error behavior by preserving `details` in cache while setting `error`. + +## Effect responsibilities after refactor + +The main effect should: + +- handle item-change bookkeeping (`prevItemIdRef`, `optimisticCommentsRef`, `setTab`) +- decide freshness and in-flight dedupe +- launch fetch and write results/errors/pending back into cache + +The effect should not call `setDetails`, `setLoading`, or `setError` for work-item details. + +## Optimistic comment race and merge rules + +`appendOptimisticComment` must write through cache only (no local `setDetails`). + +Rules: + +- push comment into `optimisticCommentsRef` +- patch cache entry comments (if present), mark stale (`fetchedAt: 0`), notify +- derive rendered comments from cached details plus optimistic refs + +Dependency pitfall: + +- do not depend on a freshly-created array from `optimisticCommentsRef.current` in `useMemo` +- memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway + +This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps. + +## Invalidation while drawer is open + +When `invalidateWorkItemDetailsCacheByMatch` deletes the current key while drawer is mounted: + +- subscription notifies immediately +- render sees `cachedEntry === undefined` +- UI falls back to loading/empty state and effect triggers refetch + +This is expected and preferred over showing stale state. + +## Fetch race constraints (must keep) + +Keep current protections: + +- per-key in-flight dedupe via `entry.pending` +- generation guard to block stale writes after invalidation +- stale-on-null handling (`result === null` must not wipe existing valid cached details) + +Add one missing consistency rule: + +- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation. + +## Scope of deletion + +Delete only the work-item-detail local state and call sites: + +- `useState` (`details`) +- `useState` (`loading`) for this data path +- `useState` (`error`) for this data path +- corresponding `setDetails`/`setLoading`/`setError` calls in the details effect and optimistic comment append path + +Do not touch unrelated loading/error state in other subviews. + +## Expected result + +Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative. diff --git a/docs/gh-work-item-drawer-cache.md b/docs/gh-work-item-drawer-cache.md new file mode 100644 index 00000000000..8e95a48a6ef --- /dev/null +++ b/docs/gh-work-item-drawer-cache.md @@ -0,0 +1,118 @@ +# GitHub Work Item Drawer: Cache & Latency + +## Problem + +`GitHubItemDialog` clears `details` to `null` on every open and waits for `gh:workItemDetails` before rendering. Reopening the same item therefore pays full IPC + `gh` process startup latency again. + +The slow path is mostly process overhead and fan-out in `src/main/github/work-item-details.ts`, not GitHub API quota. + +## What is true today + +- `gh api --cache` reduces network/API pressure, but it does not remove local process startup, JSON parsing, or IPC serialization. +- A 304 response does not consume primary REST rate-limit quota, but it is still a network round-trip. With `--cache`, many calls are served from local cache anyway. +- The issue details path is currently multi-call: +1. `getWorkItem` (`/issues/:n` or PR fallback). +2. `getIssueBodyAndComments` (`/issues/:n` and `/issues/:n/comments`). +3. `getWorkItemParticipants` (GraphQL participants). +4. `getMentionParticipants` (GraphQL user hydration for visible authors). + +So “3 spawns” understates the real hot path. + +## Goals + +- Reopen latency should be near-instant for recently viewed items. +- Cold issue-open latency should drop by reducing `gh` command count. +- Correctness must survive local mutations, repo/source switches, and multi-window usage. + +## Non-goals + +- No disk-persistent cache in this iteration. +- No attempt to replace PR files/diff path with GraphQL. + +## Design + +### 1. Renderer SWR cache with paint-first reads + +Add a module-level LRU cache in `GitHubItemDialog.tsx` keyed by: + +`repoPath + issueSourcePreference + type + number` + +Cache value: + +- `details` +- `fetchedAt` +- `pending?: Promise` +- `error?: string` + +Behavior: + +1. On open, render cached `details` immediately when present (do not clear to `null`). +2. If entry age <= `FRESH_MS` (30s), skip fetch. +3. If stale or missing, fetch in background and replace cache + UI when resolved. +4. On fetch failure with cached data, keep stale data visible and show non-blocking error state. +5. On fetch failure without cached data, show blocking error state. + +### 2. In-flight dedupe in renderer + +Store a single pending promise per key in the same cache entry. Concurrent opens/re-renders for the same key must await the same promise. + +This dedupe must be keyed the same as the data cache key to avoid cross-repo or cross-source collisions. + +### 3. Explicit invalidation rules + +“Background refetch is authoritative” is necessary but not sufficient. + +Invalidate (or patch + mark stale) on successful local mutations: + +- issue state/labels/assignees/body edits +- new comments/reactions +- PR review comment create/resolve + +Scope: + +- per-item key in current window +- broadcast to other windows via main-process event (`gh:workItemMutated`) so their caches invalidate too + +Also invalidate on context switches: + +- `repoPath` change +- issue source preference change (`origin`/`upstream`/`auto`) +- sign-out/account change + +For out-of-band mutations (web UI/other tools), rely on TTL + manual refresh action in drawer header. + +### 4. Main-process issue fetch collapse (GraphQL-first) + +Do not claim “one call returns everything” unless we actually ship and verify it. + +Feasible single GraphQL issue query fields: + +- issue body +- labels +- assignees +- participants +- comments(first: N) with author login + avatarUrl + body + createdAt + url + +Limits and required fallbacks: + +- GraphQL pagination still applies (`first: 100`). More comments require paging. +- Some comment authors can be null/ghost; renderer must keep existing fallback behavior. +- If GraphQL fails (permissions, partial errors), fall back to current REST+GraphQL path. +- Keep `getMentionParticipants` only if query omits non-participant visible authors; otherwise remove it. + +PR path remains unchanged in this doc. PR file/diff/check behavior is intentionally out of scope. + +## Edge cases this design must handle + +- Reopen same item after optimistic comment: optimistic comment must survive stale cache reads until authoritative fetch includes it. +- Switching between upstream/origin issue source with same issue number must never reuse the wrong cache entry. +- Item-type collision (`issue #123` vs `pr #123`) must never reuse cache entry. +- Drawer close/open races: stale request responses must still be dropped (`requestIdRef` guard stays). +- Unauthorized/404 should not overwrite valid cached data with empty shells. + +## Rollout + +1. Implement renderer SWR + in-flight dedupe + stale-on-error behavior. +2. Add mutation-driven invalidation and cross-window invalidation event. +3. Implement GraphQL-first issue details with strict fallback. +4. Keep telemetry: measure open-to-first-paint and open-to-fresh-data before/after. diff --git a/docs/homebrew-cask.md b/docs/homebrew-cask.md deleted file mode 100644 index 3292115c2d5..00000000000 --- a/docs/homebrew-cask.md +++ /dev/null @@ -1,90 +0,0 @@ -# Homebrew Cask - -Orca is distributed on macOS as a Homebrew Cask. This doc covers how the cask -is wired up, how it interacts with Orca's in-app updater, and what to do when -something drifts. - -## For users - -```bash -brew tap stablyai/orca -brew install --cask orca -``` - -Or in one command: `brew install --cask stablyai/orca/orca`. Both forms install -`Orca.app` into `/Applications`. Subsequent updates are handled by Orca's -in-app updater (electron-updater) — `brew upgrade` is a no-op because the -cask is marked `auto_updates true`. Users who want brew to force-reinstall -from the cask version can pass `--greedy`. - -## How the pieces fit together - -There are three moving parts: - -1. **`Casks/orca.rb`** in this repo — source of truth for the cask file. - Edited by automation on every stable release; can be edited manually if - metadata (zap list, macOS floor, desc) needs to change. -2. **`stablyai/homebrew-orca`** — the public tap users consume. Mirrors - `Casks/orca.rb` from this repo via the bump workflow. Nothing else lives - there; do not hand-edit. -3. **`.github/workflows/homebrew-bump.yml`** — runs on `release.published` - for stable tags (skips `-rc.*` and GitHub pre-releases). Downloads the - two DMGs, rewrites `version`/`sha256` in `Casks/orca.rb`, pushes a PR to - the tap, and auto-merges it. - -### Why the cask uses `auto_updates true` - -`electron-updater` (`src/main/updater.ts`) downloads each new release and -swaps `Orca.app` in place. Homebrew-Cask's tracking of installed versions is -based on the cask's `version:` field plus an install receipt — so when the -app mutates itself, brew's metadata drifts. `auto_updates true` tells -Homebrew this is expected: `brew outdated` and `brew upgrade` ignore the -cask unless `--greedy` is passed. Uninstall still works normally. - -The hidden requirement: Squirrel.Mac (what electron-updater uses) needs -write access to `/Applications/Orca.app`. Cask installs into `/Applications` -with user ownership by default, so this works out of the box. If a user ever -`sudo`-installs or the bundle becomes root-owned, the in-app updater will -fail silently; they'd need `brew reinstall --cask orca` or `brew upgrade ---cask orca --greedy` to recover. - -## One-time setup (already done, documented here for reference) - -1. **Tap repo**: `stablyai/homebrew-orca` on GitHub. Must be named - `homebrew-` so `brew tap stablyai/orca` resolves. Public. -2. **Auto-merge** enabled in tap repo settings. -3. **Seeded** with a copy of `Casks/orca.rb` for the initial version. - -The workflow authenticates as the existing `buf0-bot` GitHub App -(installed org-wide on stablyai), reusing the `BUFO_BOT_PRIVATE_KEY` -secret that's already on `stablyai/orca` for `track-community-prs.yaml`. -No PAT rotation, no new secret. - -## Submitting to homebrew-cask (the main tap) - -The `stablyai/homebrew-orca` tap ships first. Once Orca has stable user -demand and has been on a release cadence for ~30+ days without the version -string breaking conventions (no `-rc`, no date suffixes), we can submit to -`Homebrew/homebrew-cask` so users can `brew install --cask orca` without a -tap prefix. That submission is a one-time PR against -https://github.com/Homebrew/homebrew-cask; subsequent bumps to the main tap -are handled by their own [autobump infrastructure](https://docs.brew.sh/Autobump) -as long as the release cadence matches their expectations. T3 Code's -[cask](https://github.com/Homebrew/homebrew-cask/blob/main/Casks/t/t3-code.rb) -is a close structural analogue. - -## Troubleshooting - -- **"electron-updater says no update available, but brew says I'm out of - date"** — expected if the user ran `brew upgrade --greedy` or installed - the cask before the in-app updater picked up a newer release. The - `auto_updates true` flag usually prevents this; if a user reports it, - check that their cask file still has the marker. -- **Bump workflow failed to PR the tap** — verify the `buf0-bot` app is - still installed on `stablyai/homebrew-orca` (org-wide install, should - auto-cover any new org repo). Re-run via `workflow_dispatch` with the - tag name. -- **Squirrel-mac fails during update** — almost always bundle permissions - or a signing-identity mismatch. See the `Why: signing identity stability` - comment in `config/electron-builder.config.cjs` and the updater logs in - `~/Library/Application Support/Orca/logs/`. diff --git a/docs/mobile-prefer-renderer-scrollback.md b/docs/mobile-prefer-renderer-scrollback.md new file mode 100644 index 00000000000..1c9f1a254d2 --- /dev/null +++ b/docs/mobile-prefer-renderer-scrollback.md @@ -0,0 +1,651 @@ +# Mobile scrollback parity: hydrate headless emulator from renderer on first PTY data + +## Problem + +When a mobile companion client subscribes to a terminal, it sees substantially less scrollback than the desktop renderer for the same PTY. In the worst case observed (a `claude`-then-shell session in the `edit-issues` worktree), the desktop shows ~30 lines of post-exit agent summary while mobile shows a single bare prompt. + +The mismatch is reproducible and not a transient race: it persists across resubscribes, app return-from-background, and PTY-clean shell prompts. + +## Where data lives today + +Two separate xterm.js instances exist for every connected PTY: + +1. **Renderer xterm** — owned by `TerminalPane` in the renderer process. Created when the user mounts the pane. Listens to `pty:data` IPC for the lifetime of the pane. 50,000-row scrollback. Source of truth for the visible desktop terminal. + +2. **Runtime headless emulator** — `HeadlessEmulator` in `src/main/runtime/orca-runtime.ts`. Created lazily on first `runtime.onPtyData(ptyId, …)` call. 5,000-row scrollback. Used to serve `terminal.read`, `terminal.subscribe`, `lastAgentStatus`, and TUI-idle detection. This is what mobile, the CLI, and any non-renderer consumer get. + +| Consumer | Reads from | Lifetime | +|---|---|---| +| Desktop pane | Renderer xterm | Pane mount → unmount | +| Mobile (`terminal.subscribe`) | Headless emulator (primary), renderer (fallback) | Runtime startup → PTY exit | +| `terminal.read` (CLI / agent) | Headless emulator | Runtime startup → PTY exit | +| `lastAgentStatus` | Headless emulator (via OSC detection on `onPtyData`) | Runtime startup → PTY exit | + +The two xterms are fed by the same provider stream but are "born" at different moments: + +- The renderer xterm gets hydrated on attach via cold-restore data (`result.coldRestore.scrollback`) or warm reattach (`result.snapshot`) in `pty-connection.ts`, plus all live bytes since. +- The headless emulator only sees bytes that flow through `runtime.onPtyData` since *runtime* startup. After an Orca relaunch, the headless emulator starts empty and only catches up on new live data; it never replays the cold-restore payload. +- Commit `8a5ea4b7` added `seedHeadlessTerminal(ptyId, data, size?)` which runs from `ipc/pty.ts` on `provider.spawn` and seeds the emulator from `result.snapshot` / `result.coldRestore.scrollback`. That helps daemon-restored sessions but **does not help when the daemon's persisted state is itself near-empty** — the `edit-issues` reproduction has `checkpoint.json` with `scrollbackAnsi: ""` because the daemon checkpoint only stores the visible screen, not real scrollback. + +So the dominant residual case is: daemon checkpoint is near-empty, the user opens a desktop pane (which hydrates from the live PTY post-spawn and accumulates a rich 50k-row buffer), then the user opens mobile — and mobile reads from the headless emulator, which has only seen bytes since runtime startup with nothing to replay. + +## Goal + +Mobile, CLI, and `lastAgentStatus` should display the same scrollback as the desktop renderer for any PTY whenever the user has a desktop pane mounted for it. The fix must: + +- Be a small, targeted change to the runtime (no daemon, IPC schema, or persistence-format changes). +- Not regress the "no desktop pane mounted" cases (background terminals, headless agent runs, CLI on a freshly-restored daemon). +- Not introduce reflow / SGR drift between desktop and mobile. +- Not introduce a per-keystroke or per-subscribe renderer round-trip on the hot path. +- Reinforce — not violate — the existing invariant that the main process is authoritative for terminal state served to mobile/CLI/`terminal.read`. + +## Non-goals + +- **Mobile-only-after-relaunch (Scenario A).** A user who relaunches Orca and opens mobile *without* ever opening the desktop pane will still see only what the daemon checkpoint stored (often the visible screen and nothing more). Solving this requires daemon-side persisted scrollback, which is a separate, larger effort with its own privacy, format-stability, retention, and IO-cost considerations. This design explicitly does not solve Scenario A. +- Persisting full scrollback to disk (the "option 2" architecture). +- Unifying the renderer and headless emulators into a single main-process service. +- Changing mobile's xterm-side replay logic. Today it correctly handles either source. +- Changing the read-priority order in `serializeTerminalBufferFromAvailableState`. Headless stays primary; renderer stays the no-headless fallback. + +## Proposed change + +When the runtime first sees `pty:data` for a `ptyId` that already has a renderer pane registered (detected via a new `ptyController.hasRendererSerializer(ptyId)` predicate, backed by the `serializersByPtyId` registry in `pty-buffer-serializer.ts`), perform a one-time IPC round-trip to the renderer, get its serialized buffer, and seed the headless emulator with it **before** writing the live byte that just arrived. Subsequent `pty:data` calls for the same `ptyId` skip hydration via a per-PTY guard. If no renderer serializer is registered yet on the first byte, the guard is left absent so a later byte can retry once the pane mounts. + +This is structurally the same operation `seedHeadlessTerminal` already does on `provider.spawn` from daemon-restored state. The new path covers the gap the existing seeding doesn't: the renderer has a richer buffer than the daemon does, and we want that richer buffer mirrored into the headless emulator on first runtime touch. + +After hydration, all consumers (`terminal.read`, `terminal.subscribe`, `lastAgentStatus`, mobile, CLI) get the same fuller scrollback through the existing read paths. No priority flip is required. No per-consumer fallback. No per-subscribe IPC. + +### System context — before + +``` + ┌─────────────────────────────────────────────┐ + │ Renderer process │ + │ │ + │ TerminalPane → xterm.js (50k rows) │ + │ ▲ │ + │ │ replayIntoTerminal(coldRestore. │ + │ │ scrollback) on pane mount │ + │ │ + live pty:data writes │ + └─────┼───────────────────────────────────────┘ + │ + pty:data│IPC + │ + ┌─────┴───────────────────────────────────────┐ + │ Main process │ + │ │ + │ ipc/pty.ts (provider.spawn) │ + │ ├─→ seedHeadlessTerminal(snapshot) │ + │ │ ↓ │ + │ │ HeadlessEmulator (5k rows) ───┐ │ + │ │ ▲ │ │ + │ │ │ trackHeadlessTerminalData│ │ + │ │ │ on every onPtyData │ │ + │ └─→ runtime.onPtyData(...)────────┘ │ + │ ▼ │ + │ serializeHeadless │ + │ (primary read source) │ + │ │ │ + │ ▼ │ + │ mobile / CLI / lastStatus │ + └─────────────────────────────────────────────┘ + ▲ + pty:data│from daemon + │ + ┌─────┴───────────────────────────────────────┐ + │ Daemon │ + │ checkpoint.json (visible screen only) │ + └─────────────────────────────────────────────┘ + +Bug: when daemon checkpoint is near-empty but the renderer xterm has +accumulated a rich live buffer, mobile reads from the headless emulator +which only saw bytes since runtime startup — and so sees less than desktop. +``` + +### System context — after + +``` + ┌─────────────────────────────────────────────┐ + │ Renderer process │ + │ │ + │ TerminalPane → xterm.js (50k rows) │ + │ │ ▲ │ + │ │ │ unchanged hydration path │ + │ │ └─── live pty:data writes │ + │ │ │ + │ │ ptyController.serializeBuffer(ptyId) │ + │ │ called ONCE per (ptyId, runtime) │ + │ │ on first onPtyData │ + │ ▼ │ + └─────┼───────────────────────────────────────┘ + │ + pty:data│IPC + one-time serialize round-trip + │ + ┌─────┴───────────────────────────────────────┐ + │ Main process │ + │ │ + │ onPtyData(ptyId, data) (first time) │ + │ ├─→ if ptyController.serializeBuffer │ + │ │ returns non-empty: │ + │ │ hydrateHeadlessFromRenderer → │ + │ │ seedHeadlessTerminal(rendered) │ + │ │ mark hydrated │ + │ ├─→ trackHeadlessTerminalData(data) │ + │ │ (chained via writeChain after seed) │ + │ │ │ + │ HeadlessEmulator (5k rows, now seeded │ + │ with renderer's view) │ + │ ↓ │ + │ serializeHeadless (UNCHANGED PRIMARY) │ + │ ↓ │ + │ mobile / CLI / lastAgentStatus / read │ + └─────────────────────────────────────────────┘ + +Read priorities are unchanged. Headless emulator is still the primary +source of truth; renderer is consulted exactly once, at hydration time, +not per consumer or per subscribe. +``` + +### Ordering invariant (DO NOT REORDER) + +In `onPtyData(ptyId, data, at)`, the call sequence is: + +```ts +this.agentDetector?.onData(ptyId, data, at) +this.maybeHydrateHeadlessFromRenderer(ptyId) // ← MUST come BEFORE +this.trackHeadlessTerminalData(ptyId, data) // ← this line +``` + +**`maybeHydrateHeadlessFromRenderer` MUST be invoked before `trackHeadlessTerminalData`.** If reordered, `trackHeadlessTerminalData`'s lazy-create branch will populate `headlessTerminals` with a fresh empty emulator first, and `maybeHydrateHeadlessFromRenderer`'s `!headlessTerminals.has(ptyId)` precondition (the "live bytes already arrived" guard) will short-circuit — silently disabling hydration for the lifetime of the PTY. There is no test that catches this regression cheaply (the failure mode is "less scrollback than expected", which is the original bug); the guarantee lives in the call-site comment in `orca-runtime.ts`. + +A `// DO NOT REORDER — see "Ordering invariant" in mobile-prefer-renderer-scrollback.md` comment is mandatory at this call site. + +### Pseudocode + +```ts +// Per-PTY hydration tracker. Key absent = idle (next byte may attempt). +// 'pending' = round-trip in flight; 'done' = succeeded or unrecoverably +// finished; 'skipped' is no longer used — instead we leave the key absent +// when the precondition fails so a later byte can retry. +private headlessHydrationState = new Map() + +onPtyData(ptyId: string, data: string, at: number): void { + this.agentDetector?.onData(ptyId, data, at) + this.maybeHydrateHeadlessFromRenderer(ptyId) + this.trackHeadlessTerminalData(ptyId, data) + // …existing OSC / tail / leaf bookkeeping unchanged +} + +private async maybeHydrateHeadlessFromRenderer(ptyId: string): Promise { + if (this.headlessHydrationState.has(ptyId)) return + if (this.headlessTerminals.has(ptyId)) { + // Live bytes already arrived for this ptyId — re-seeding now would + // duplicate them. Mark done; never retry. (Daemon-snapshot seeding + // is handled separately: see "Cooperation with seedHeadlessTerminal" + // — that path is gated on `!hasRendererSerializer(ptyId)` so it does + // not pre-empt this hydration when a renderer pane is mounted.) + this.headlessHydrationState.set(ptyId, 'done') + return + } + // Why: leave the state absent (not 'done') when there's no renderer + // serializer or controller. The pane may mount one byte later; the + // next onPtyData call gets another chance. This is the contract change + // motivated by Open Question P2-2. + if (!this.ptyController?.serializeBuffer) return + if (!this.ptyController.hasRendererSerializer?.(ptyId)) return // not yet registered + + this.headlessHydrationState.set(ptyId, 'pending') + + // Why: eagerly create the headless state at PTY dims so concurrent live + // writes from trackHeadlessTerminalData chain onto the same writeChain. + // This matches seedHeadlessTerminal's pattern (orca-runtime.ts:780-803); + // without it, a live byte arriving during the serializeBuffer await would + // lazy-create a separate state that we'd later overwrite, dropping the + // live byte. + const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state: RuntimeHeadlessTerminal = { + emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + writeChain: Promise.resolve() + } + this.headlessTerminals.set(ptyId, state) + + // Why: append the renderer-fetch + seed-write to the chain. Live writes + // that arrive before the chain head resolves are queued behind it via + // trackHeadlessTerminalData's existing `state.writeChain.then(...)` pattern. + state.writeChain = state.writeChain.then(async () => { + try { + const rendered = await this.ptyController!.serializeBuffer!(ptyId, { + scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, + altScreenForcesZeroRows: true + }) + if (!rendered || rendered.data.length === 0) { + this.headlessHydrationState.set(ptyId, 'done') + return + } + // Why: resize to the renderer's reported dims before writing the seed + // so the serialized layout reflows correctly. Then resize back to the + // PTY's current dims after the seed lands so subsequent live writes + // use the right cell grid. + state.emulator.resize(rendered.cols, rendered.rows) + await state.emulator.write(rendered.data) + const ptyDims = this.getTerminalSize(ptyId) + if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { + state.emulator.resize(ptyDims.cols, ptyDims.rows) + } + // Why: status parity. SerializeAddon does NOT round-trip OSC 0/1/2 + // title bytes, so extracting from rendered.data would always be null. + // applySeededAgentStatus runs detectAgentStatusFromTitle and writes + // leaf.lastAgentStatus only when the result is non-null; it MUST NOT + // call resolveTuiIdleWaiters or deliverPendingMessages because seed- + // derived status reflects historical state. + if (rendered.lastTitle) { + this.applySeededAgentStatus(ptyId, rendered.lastTitle) + } + } catch { + // Hydration is best-effort. Live writes continue via the same + // writeChain that this catch-arm leaves intact. + } finally { + this.headlessHydrationState.set(ptyId, 'done') + } + }) +} +``` + +**Eager-state ordering invariant.** Even though the chain head blocks live writes until the seed completes, the LIVE BYTE ITSELF (the one that triggered `onPtyData`) is enqueued onto `state.writeChain` BY `trackHeadlessTerminalData` AFTER `maybeHydrateHeadlessFromRenderer` returns synchronously and registers its seed-write link. The live byte's chain link executes after the seed-write resolves. That is the ordering invariant the eager-state pattern preserves. + +**Resize-during-hydration note.** `resizeHeadlessTerminal` (orca-runtime.ts:828) deliberately bypasses `writeChain` and calls `state.emulator.resize(...)` directly — that's existing behavior we preserve. If a `pty:resize` IPC arrives during the hydration window (between `headlessTerminals.set` and `seedPromise` resolution), the resize lands on the emulator while the seed-write is mid-flight; this can produce a one-frame visual artifact in the seed but cannot corrupt subsequent live writes (which the seed-then-resize-back invariant restores). The narrow window — typically <200ms — is bounded by the renderer-IPC round-trip duration. + +The cap and alt-screen rule on the renderer side mirror `serializeHeadlessTerminalBuffer`: 1000-row limit on rehydration payload, force `scrollbackRows = 0` when the renderer xterm reports `buffer.active.type === 'alternate'`. This unifies semantics between hydration source and live-serialize source — the user sees the same depth regardless of which path served them, and an alt-screen TUI never bleeds normal-buffer scrollback into the seed. + +### IPC contract changes + +Hydration relies on the renderer-side serializer accepting an option bag. Today the chain is single-arg end to end, so changes must land in lockstep: + +- `src/preload/index.ts` — the `pty:serializeBuffer:request` payload extends to `{ requestId, ptyId, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } }`. Renderer-side type for the listener callback widens accordingly. +- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts the opts object and forwards it on the IPC `webContents.send` payload. Snapshot-shape validation widens to accept an optional `lastTitle: string`. Existing 750 ms timeout unchanged. +- `src/main/runtime/orca-runtime.ts` — the `RuntimePtyController.serializeBuffer?` signature widens to `(ptyId, opts?) => Promise<{ data: string, cols: number, rows: number, lastTitle?: string } | null>`. A new optional `hasRendererSerializer?(ptyId): boolean` lets the runtime check pane mount status before paying the IPC cost (implementation: a getter against `serializersByPtyId` in `pty-buffer-serializer.ts`). +- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — the registered `SerializeFn` widens to `(opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`. The pane's serializer reads the opts: if `altScreenForcesZeroRows && pane.terminal.buffer.active.type === 'alternate'`, call `pane.serializeAddon.serialize({ scrollback: 0 })`; otherwise `pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows })`. The response payload includes `lastTitle: lastTitleByPtyId.get(ptyId)?.title` (omitted when no title has been observed for this PTY). Default behavior (no opts) preserves today's full-buffer serialize for any caller that hasn't migrated. +- `serializeTerminalBufferFromAvailableState` (the renderer-fallback read path in `orca-runtime.ts:843`) forwards `scrollbackRows` so the fallback's depth matches the primary path. **It does NOT pass `altScreenForcesZeroRows: true`** — see "alt-screen flag scoping" below. + +**alt-screen flag scoping (only on hydration path).** `altScreenForcesZeroRows: true` is passed ONLY by `maybeHydrateHeadlessFromRenderer` (the hydration path). The read-fallback path (`serializeTerminalBufferFromAvailableState`) passes `altScreenForcesZeroRows: false` (or omits the flag, default false). Reasoning: + +- **Hydration:** wants the *normal* buffer's scrollback. xterm discards alt-screen content when alt-screen exits anyway, so suppressing scrollback during alt-screen prevents transient TUI bytes from bleeding into the seed that the headless emulator will hold for the rest of the session. +- **Read fallback:** is invoked by `terminal.read` / `terminal.subscribe` for an actively-running TUI when the headless emulator returned null. The caller wants whatever is currently visible — including alt-screen content — so suppressing it would produce a worse result than today. + +Both call sites pass explicit opts in the pseudocode (no implicit defaults relied on). The read-fallback shape: + +```ts +// In serializeTerminalBufferFromAvailableState (read-fallback): +rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, { + scrollbackRows: opts.scrollbackRows, + altScreenForcesZeroRows: false // read-fallback wants visible alt-screen content +}) ?? Promise.resolve(null)) +``` + +### Renderer-side prerequisite + +Today, `registerPtySerializer(ptyId, …)` is only called inside `handleReattachResult` (`pty-connection.ts:571`). Fresh spawns — the dominant case — never register, so `hasRendererSerializer(ptyId)` would always return false and hydration would no-op. The renderer change required: + +- Move (or duplicate) the `registerPtySerializer` call so it fires for **both** reattach and fresh spawn paths. Cleanest shape: extract the body (closure capturing `pane`, `deps.pendingWritesRef`, `deps.replayingPanesRef`) into a `registerPaneSerializerFor(ptyId)` helper and call it from `handleReattachResult` AND from `startFreshSpawn`'s `.then(spawnedPtyId => …)` once a `ptyId` is known. +- The helper accepts the new `opts` arg and threads it into `pane.serializeAddon.serialize({ scrollback: … })` per the IPC contract above. + +**Cleanup contract & StrictMode safety.** Two requirements, both motivated by React 18 StrictMode double-mount and async pane disposal: + +1. **`if (disposed) return` guard at the top of every post-spawn registration call** (both reattach and fresh-spawn paths). Rationale: StrictMode mounts the pane twice; the first mount is disposed before the second runs, but its `pty:spawn` IPC may have already resolved by the time `disposed` flips to true. Without the guard, the disposed first mount would call `registerPaneSerializerFor` after teardown — replacing the live registration from the second mount or registering a closure over a torn-down `pane.terminal`. + +2. **Unregister via ownership token, not unconditional delete.** `serializersByPtyId` becomes `Map`. Each call to `registerPtySerializer` mints a fresh `owner` symbol and returns an unregister closure that **only deletes the entry when the current entry's `owner === thisOwner`**. If a later registration has overwritten the entry (StrictMode second mount, fast pane swap), the unregister is a no-op so the live registration is preserved. The body of the unregister closure also clears any module-level state keyed off the same owner (lastTitle bookkeeping introduced below) AND disposes the xterm `onTitleChange` IDisposable that was installed at register time. + + ```ts + export function registerPtySerializer(ptyId, fn): () => void { + const owner = Symbol(ptyId) + serializersByPtyId.set(ptyId, { fn, owner }) + ensureSerializerListener() + return () => { + const current = serializersByPtyId.get(ptyId) + if (current?.owner === owner) { + serializersByPtyId.delete(ptyId) + } + } + } + ``` + +3. **Spawn-rejection cleanup is mandatory.** The renderer MUST call `pty:clearPendingPaneSerializer(paneKey, gen)` from the catch-arm of `pty:spawn` AND from pane unmount AFTER pre-signal but BEFORE settle. Without this, a failed-spawn paneKey leaks a pending intent that suppresses the next legitimate daemon-snapshot seed for that paneKey. + +4. **Pre-signal applies to BOTH fresh-spawn AND reattach paths.** `declarePendingPaneSerializer(paneKey)` is mandatory on both — mirroring the `registerPaneSerializerFor(ptyId)` requirement. Reattach paths today already register the serializer in `handleReattachResult`, but the pre-signal must also fire on the reattach path so the cooperation gate in `pty:spawn` behaves consistently regardless of whether the renderer is taking over a pre-existing PTY or a fresh one. + +The `unregisterSerializer()` call is wired into `onDataDisposable.dispose` so the registration is torn down on pane unmount, regardless of which path registered it. + +### Renderer pre-signal handshake + +The cooperation gate (`ipc/pty.ts` skipping `seedHeadlessTerminal` when a renderer serializer is registered) has a fundamental timing problem on **fresh spawns**: the renderer cannot have registered the serializer for the new ptyId yet, because it doesn't know the ptyId until `pty:spawn` resolves. So at the moment the cooperation gate runs (immediately after `provider.spawn` returns inside `pty:spawn`'s handler), `runtime.hasRendererSerializerFor(ptyId) === false` for every fresh spawn — and the daemon-snapshot seed always wins, defeating the gate for the dominant case. + +The renderer does, however, know its **paneKey** before spawn: every pane has a stable `ORCA_PANE_KEY` env var that is part of `args.env` on `pty:spawn`. We use the paneKey as a pre-signal, on a channel that doesn't depend on knowing the ptyId yet: + +1. **Pre-signal IPC with generation token.** Renderer calls `pty:declarePendingPaneSerializer(paneKey)` BEFORE invoking `pty:spawn`. Main process maintains `pendingByPaneKey: Map` keyed by paneKey. On each pre-signal, main mints a fresh monotonic `gen` value (incrementing a module-level `genSeq` counter) and overwrites the entry, then returns `gen` to the renderer. The renderer awaits this IPC (capturing `gen`), then awaits the spawn IPC — guaranteeing the main process has the pending intent recorded by the time `pty:spawn` runs. + + ```ts + let genSeq = 0 + const pendingByPaneKey = new Map() // paneKey -> generation + + function declarePending(paneKey: string): number { + const gen = ++genSeq + pendingByPaneKey.set(paneKey, gen) + return gen // returned to renderer, echoed back on settle/clear + } + + function settlePending(paneKey: string, gen: number): void { + if (pendingByPaneKey.get(paneKey) === gen) pendingByPaneKey.delete(paneKey) + } + + paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen)) + ``` + + Why a generation counter: today's design hooks `pendingByPaneKey.delete(paneKey)` into `clearProviderPtyState` via `paneKeyTeardownListeners`. If the OLD PTY's `clearProviderPtyState` fires AFTER mount-2's pre-signal but BEFORE mount-2's spawn, an unconditional teardown deletes mount-2's pending entry — defeating the gate. The generation counter solves this: the `paneKeyTeardownListeners` wiring captures the generation at the time the old PTY was REGISTERED (via the existing `registerPty` call site) and the teardown only fires `settlePending` with that generation. If mount-2 has already replaced the entry with a new gen, `pendingByPaneKey.get(K) !== oldGen` and the teardown is a no-op. + +2. **Cooperation gate consults the pre-signal.** Inside `pty:spawn`, after `provider.spawn` returns, the gate becomes: + ```ts + const paneKey = args.env?.ORCA_PANE_KEY + const rendererPreSignaled = + typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256 + && pendingByPaneKey.has(paneKey) + const rendererAlreadyRegistered = + runtime?.hasRendererSerializerFor?.(result.id) ?? false + if (!rendererPreSignaled && !rendererAlreadyRegistered) { + // No renderer is or will be authoritative for this PTY — daemon-snapshot + // seed runs as today. + runtime?.seedHeadlessTerminal(result.id, /* …snapshot/coldRestore… */) + } + ``` + The `rendererAlreadyRegistered` branch covers reattach, where the pane mounted before spawn returned and registration completed in the meantime. The `rendererPreSignaled` branch covers fresh spawn, where registration has not happened yet but is imminent. Note: the gate matches when `pendingByPaneKey.has(paneKey)` is true *regardless of generation*; the generation is only used by cleanup paths (teardown listeners, settle, clear) to prevent cross-generation deletion. **Invariant:** fresh-spawn always relies on the pre-signal branch (`hasRendererSerializerFor(ptyId)` is necessarily false at gate-time since the renderer hasn't received `ptyId` yet); the registered branch only fires for reattach where the pane mounted before spawn returned. + +3. **Settle / clear (echo gen back).** After the spawn IPC resolves and the renderer calls `registerPtySerializer(ptyId, …)`, it calls `pty:settlePaneSerializer(paneKey, gen)` to remove the pending intent (echoing the `gen` value it captured from declarePending). The handler runs `settlePending(paneKey, gen)` which only deletes if the stored gen still matches. (The simpler "clear on first onPtyData" or "clear on receipt of `pty:registerPaneSerializer`" alternatives work too — the design picks the explicit `settle` IPC because it makes the contract auditable from the renderer side and avoids coupling main-process state to onPtyData flow.) On pane unmount or registration failure, the renderer also calls `pty:clearPendingPaneSerializer(paneKey, gen)` (sibling handler with the same `settlePending` semantics) to drop the pending intent. + +**Sequence diagram (fresh spawn, happy path):** + +``` +Renderer Main / ipc-pty.ts Runtime + │ │ │ + │ pty:declarePendingPaneSerializer │ │ + │ (paneKey) ───────────────────────────▶│ pendingByPaneKey.set(K, gen) │ + │ ◀──────────────────────── ack(gen) ──│ │ + │ │ │ + │ pty:spawn(args incl. ORCA_PANE_KEY) ─▶│ │ + │ │ provider.spawn(...) → result │ + │ │ pendingByPaneKey.has(paneKey)│ + │ │ === true → SKIP │ + │ │ seedHeadlessTerminal │ + │ │ │ + │ ◀──────────────────────────── ptyId ──│ │ + │ │ │ + │ registerPtySerializer(ptyId, fn) │ │ + │ pty:settlePaneSerializer(paneKey, │ │ + │ gen) ──────────────────────────────▶│ settlePending(paneKey, gen) │ + │ │ if gen matches → delete │ + │ │ │ + │ │ first onPtyData(ptyId,data)──▶│ + │ │ │ maybeHydrateFromRenderer + │ │ │ (headlessTerminals empty, + │ │ │ hasRendererSerializer true + │ │ │ → IPC round-trip → seed) +``` + +**Renderer-side call shape.** The renderer threads `gen` through every call site so cleanup (settle/clear) can be matched against the generation it captured at declare time: + +```ts +// In pty-connection.ts startFreshSpawn / handleReattachResult: +const gen = await window.api.pty.declarePendingPaneSerializer(paneKey) +try { + const ptyId = await window.api.pty.spawn(args) + registerPaneSerializerFor(ptyId) + await window.api.pty.settlePaneSerializer(paneKey, gen) +} catch (err) { + await window.api.pty.clearPendingPaneSerializer(paneKey, gen) + throw err +} +``` + +**Correctness properties:** + +1. **Pre-signal arrives before `pty:spawn`.** The renderer awaits the pre-signal IPC, then awaits the spawn IPC. Electron `ipcRenderer.invoke`/`send` ordering on a single renderer is preserved on the main side, so the main process processes the pre-signal before `pty:spawn`'s handler runs. +2. **Missing paneKey degrades to today's behavior.** If `args.env?.ORCA_PANE_KEY` is absent (e.g., a pane that isn't yet wired to set it, an old renderer build, or a non-pane spawn path), the pre-signal lookup returns false, `rendererAlreadyRegistered` is also false on a fresh spawn, and the daemon-snapshot seed runs as today. No regression for callers that don't participate in the handshake. +3. **Pre-signal without settle (renderer crashes between spawn and registerPtySerializer).** The pending intent stays in the map, but it has no observable effect: the cooperation gate already ran, so the daemon snapshot was suppressed; the headless emulator stays empty; the first `onPtyData` falls through `hasRendererSerializer(ptyId) === false` and hydration's precondition leaves the state map absent. `trackHeadlessTerminalData` then lazy-creates the emulator and live writes populate it. We lose the daemon snapshot for this PTY in that narrow window — accepted limitation, since the renderer was clearly going to be authoritative but didn't make it. (To bound staleness, the entry in `pendingByPaneKey` is cleared whenever a paneKey teardown event fires via the existing `paneKeyTeardownListeners` registry, gated by the generation token.) + +**Files-touched implications:** see "IPC contract changes" and "Files touched" — both `pty:declarePendingPaneSerializer` and `pty:settlePaneSerializer` need preload bindings AND main handlers. + +### Status parity + +Seeding the emulator bypasses `onPtyData`'s OSC-extraction path, so without intervention `leaf.lastAgentStatus` stays whatever it was (typically `null` for daemon-restored leaves) until the next live byte happens to carry an OSC title. Mobile would then see a stale or missing agent badge despite a rich seed. + +A previous draft of this design called `extractLastOscTitle(rendered.data)` on the seed payload to recover the title. **That doesn't work**: xterm's `SerializeAddon.serialize()` writes out the visible buffer as a stream of cursor moves, SGR resets, and printable characters — it does NOT round-trip OSC 0/1/2 (window/icon title) escape sequences. The renderer xterm consumes the OSC and sets `terminal.options.title`, but the bytes never appear in `serialize()`'s output. So `extractLastOscTitle` on a seed payload is dead code — it always returns null even when the renderer's pane currently shows a perfectly good title. We need the title via a different channel. + +**Approach: ship now via IPC widening, document as best-effort.** The renderer pane already wires `pane.terminal.onTitleChange` (in `pty-connection.ts`) to keep the tab title in sync. We capture the latest observed title in module state alongside the serializer registration, and widen the serialize-buffer IPC payload to carry it back to main: + +1. **Renderer side** (`pty-buffer-serializer.ts`): + - Module-level `lastTitleByPtyId: Map` keyed by the same ownership-token symbol as `serializersByPtyId` (see "Renderer-side prerequisite"). Populated by a wrapper around `pane.terminal.onTitleChange` installed at the same time as `registerPtySerializer`. The entry stores BOTH the IDisposable returned from `pane.terminal.onTitleChange(...)` AND the owner symbol alongside the latest title. + - When the pane unregisters, the `unregisterSerializer` closure disposes the IDisposable AND deletes the map entry, both gated by the owner-token match — same ownership semantics as the serializer map, so a StrictMode disposed first-mount cannot wipe the live second-mount's title or leak a dangling listener. + - The IPC response payload widens from `{ data, cols, rows }` to `{ data, cols, rows, lastTitle?: string }`. Default omitted when no title was observed for this PTY (e.g., fresh shell pre-prompt). + +2. **Main side** (`ipc/pty.ts`, `orca-runtime.ts`): + - `requestSerializedBuffer` snapshot-shape validation accepts `lastTitle?: string`. + - `RuntimePtyController.serializeBuffer(ptyId, opts?)` returns `Promise<{ data, cols, rows, lastTitle?: string } | null>`. + - `maybeHydrateHeadlessFromRenderer` calls `applySeededAgentStatus(ptyId, rendered.lastTitle)` only when `rendered.lastTitle` is a non-empty string (the helper itself also short-circuits on null/empty for safety). The helper internally runs `detectAgentStatusFromTitle` and only writes `leaf.lastAgentStatus` when the result is one of `'idle' | 'working' | 'permission'`. + +3. **`applySeededAgentStatus(ptyId, title)`** (new helper in `orca-runtime.ts`). Behavior: + - If `title == null` or empty, no-op. + - Look up the leaf for `ptyId` via the existing pty→leaf mapping. Run `detectAgentStatusFromTitle(rendered.lastTitle)` (the helper from `src/shared/agent-detection.ts`). If the result is non-null, set `leaf.lastAgentStatus = result`. If null (title is not a recognizable agent title), leave `lastAgentStatus` untouched — the next live OSC byte will populate it through the normal onPtyData path. + - **MUST NOT** call `resolveTuiIdleWaiters(leaf)` or `deliverPendingMessages(leaf)`. The seeded status reflects the renderer's last-observed historical title, not a live transition; resolving idle waiters or delivering pending messages on stale data could mistakenly unblock orchestration callers that should only react to a fresh prompt-return. + - **Why** (mandatory comment in the helper definition): `// seed-derived status reflects historical state; orchestration waiters must only react to live transitions to avoid resolving on stale data.` + - **Rationale for routing through `detectAgentStatusFromTitle`:** we mirror the live path (`onPtyData` already uses `detectAgentStatusFromTitle`) so seeded and live paths produce the same union value, and downstream `leaf.lastAgentStatus === 'idle'` consumers (orchestration `tui-idle` waiters, pending-message gating) keep working. `leaf.lastAgentStatus` is typed `AgentStatus | null` where `AgentStatus = 'working' | 'idle' | 'permission'`; assigning the raw title string would be type-incorrect. + +This delivers status parity for the dominant case via the `lastTitle` field (any pane that has rendered a prompt or had a TUI emit OSC titles will have populated `lastTitleByPtyId` long before mobile subscribes). Edge cases where the renderer never observed a title — fresh shell pre-prompt, pane that mounted but never received bytes — leave `lastAgentStatus` as `null`; that's acceptable since `lastAgentStatus` is documented best-effort and the next live OSC title will populate it through the normal `onPtyData` path. + +**Status parity / accepted limitations.** Side effect of seeding `lastAgentStatus`: if the seed sets status to `'idle'`, the next live OSC byte that re-emits the same `'idle'` title will see `prevStatus === 'idle'` and the existing transition gate (`agentStatus === 'idle' && prevStatus !== 'idle'`) skips. Orchestration `tui-idle` waiters registered between seeding and the next idle transition will hang until either (a) a non-idle status arrives followed by another idle transition, or (b) the waiter times out. This is an accepted limitation. If it becomes a real problem, future work could split into `lastSeededAgentStatus` (informational, not gating) vs `lastAgentStatus` (gates transitions). + +### Files touched + +- `src/main/runtime/orca-runtime.ts` — add `headlessHydrationState`, `maybeHydrateHeadlessFromRenderer`, wire into `onPtyData` (with mandatory `// DO NOT REORDER` comment per "Ordering invariant"). Widen `RuntimePtyController.serializeBuffer?` to `(ptyId, opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null>` and add `hasRendererSerializer?(ptyId)`. Add `applySeededAgentStatus(ptyId, title)` helper that writes `leaf.lastAgentStatus` only — must NOT call `resolveTuiIdleWaiters` or `deliverPendingMessages` (mandatory "Why" comment in the helper definition). Add `hasRendererSerializerFor(ptyId)` runtime-service-level method that delegates to `ptyController.hasRendererSerializer` (consumed by `ipc/pty.ts`'s cooperation gate). Forward `scrollbackRows` from `serializeTerminalBufferFromAvailableState` into the renderer-fallback call (do NOT pass `altScreenForcesZeroRows: true` on this path — see "alt-screen flag scoping"). +- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts opts and forwards them on the `pty:serializeBuffer:request` IPC payload. Wire `hasRendererSerializer` through a renderer-registry getter. **Cooperate with hydration:** when a renderer serializer is registered for the spawning PTY *or* the renderer pre-signaled intent for `args.env.ORCA_PANE_KEY`, skip seeding from `result.snapshot` / `result.coldRestore.scrollback` so the daemon's bare-prompt state cannot pre-empt the richer renderer hydration that follows on first byte. **Add three new IPC handlers** with consistent paneKey validation (`typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256` — invalid input is rejected before mutating state): `pty:declarePendingPaneSerializer(paneKey): Promise` (renderer → main, before `pty:spawn`) mints a fresh generation `gen`, stores `pendingByPaneKey.set(paneKey, gen)` in a module-level `pendingByPaneKey: Map`, and returns `gen` to the renderer; `pty:settlePaneSerializer(paneKey, gen)` (renderer → main, after `registerPtySerializer`) runs `settlePending(paneKey, gen)` which deletes only if the stored gen still matches; `pty:clearPendingPaneSerializer(paneKey, gen)` (renderer → main, on spawn-rejection / unmount-before-settle) has the same `settlePending` semantics. Hook `paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))` so a torn-down PTY's listener captures the generation it was registered under and only clears the map when that generation still matches — ensuring a teardown for the OLD PTY cannot delete a NEW PTY's pending intent if mount-2's pre-signal landed in between. (See "Renderer pre-signal handshake" and "Cooperation with seedHeadlessTerminal" in Open questions.) +- `src/preload/index.ts` — extend the `pty:serializeBuffer:request` listener payload type to include `opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }`. Add three new bindings under `pty`: `declarePendingPaneSerializer(paneKey: string): Promise` (returns the `gen` token), `settlePaneSerializer(paneKey: string, gen: number): Promise`, and `clearPendingPaneSerializer(paneKey: string, gen: number): Promise`, all forwarded as `ipcRenderer.invoke('pty:declarePendingPaneSerializer', …)` / `'pty:settlePaneSerializer'` / `'pty:clearPendingPaneSerializer'` so the renderer can `await` them and rely on main-side ordering before issuing `pty:spawn` / after `registerPtySerializer`. The renderer captures `gen` from declare and threads it through every settle/clear call. +- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — widen `SerializeFn` to `(opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`; default to today's full-buffer behavior when opts is absent. Convert `serializersByPtyId: Map` to `Map` and have `registerPtySerializer` mint a fresh owner symbol per call, returning an unregister closure that deletes only when `current?.owner === thisOwner` (StrictMode safety — see "Renderer-side prerequisite"). Add a parallel `lastTitleByPtyId: Map` populated by a wrapper around `pane.terminal.onTitleChange`, with the unregister closure both disposing the IDisposable AND deleting the entry under the same owner-token check. The serialize listener reads from `lastTitleByPtyId` to populate `lastTitle` in the IPC response. +- `src/renderer/src/components/terminal-pane/pty-connection.ts` — register the serializer for **fresh spawns** as well as reattach. Extract the registration body into a shared `registerPaneSerializerFor(ptyId)` helper called from both `handleReattachResult` AND the post-spawn callback in `startFreshSpawn`. Thread opts (scrollback cap, alt-screen flag) into `pane.serializeAddon.serialize`. Add `if (disposed) return` guard at the top of every post-spawn registration call (StrictMode safety — see "Renderer-side prerequisite"). Track the latest `pane.terminal.onTitleChange` value in module state alongside the serializer registration so the serializer's IPC response can include `lastTitle` (see "Status parity"). Call `const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)` BEFORE `pty:spawn` on every fresh-spawn path (capturing the returned generation token), and `settlePaneSerializer(paneKey, gen)` AFTER `registerPaneSerializerFor(ptyId)` succeeds. On spawn-rejection (catch-arm) AND on pane unmount AFTER pre-signal but BEFORE settle, call `clearPendingPaneSerializer(paneKey, gen)` — this is mandatory (see "Renderer-side prerequisite" item 3). Unregister on pane unmount via the existing `onDataDisposable.dispose` hook (now using the ownership-token contract from "Renderer-side prerequisite"). +- `src/main/runtime/scrollback-limits.ts` (new) — main-process module exporting `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000`. Imported by both `src/main/runtime/rpc/methods/terminal.ts` and `src/main/runtime/orca-runtime.ts`. (The constant is main-process-only; promoting it to `src/shared/` would expose a runtime cap to the renderer for no benefit.) +- `src/main/runtime/orca-runtime.test.ts` — add hydration unit tests (see "Test plan"). +- `src/main/runtime/mobile-subscribe-integration.test.ts` — extend with cross-process hydration scenario. + +No changes to: +- `pty:data` batching, daemon adapter. +- `serializeTerminalBufferFromAvailableState` priority order (headless still primary; renderer still no-headless fallback). +- Mobile RPC method signatures (`terminal.subscribe`, `terminal.resizeForClient`). +- Mobile WebView / `TerminalWebView.tsx`. +- Persistence (`HistoryManager`, `checkpoint.json` shape). + +## Why this is structurally sound + +The renderer xterm is, in practice, the fuller and more authoritative state for any mounted pane: + +1. **It hydrates on attach.** `pty-connection.ts` calls `replayIntoTerminal(pane, …, coldRestore.scrollback)` and writes `snapshot` immediately after pane mount. The headless emulator never replays either. So even on a session that started before the current Orca run, the renderer xterm contains the full history; the headless emulator does not. + +2. **It has a bigger buffer.** 50,000 rows vs 5,000. + +3. **It is updated on the same IPC tick as the visible UI.** No staleness vs what the user sees. + +Hydrating the headless emulator from the renderer on first runtime touch is the same shape of operation as the existing `seedHeadlessTerminal(provider.spawn snapshot)` path. We are not introducing a new architectural pattern; we are extending an existing one to the case where the daemon snapshot is empty but the renderer is rich. The runtime remains authoritative for everything mobile, CLI, `terminal.read`, and `lastAgentStatus` see — the renderer is consulted exactly once per `(ptyId, runtime)` pair. + +This is a structural-adjacent improvement that fixes the dominant case (user has both desktop and mobile open) while consciously deferring Scenario A (mobile-only after relaunch) to a separate daemon-persistence design. It is honest about what it does and does not solve. + +## Data flow paths + +### Happy path — renderer mounted, hydration succeeds + +``` +T0 user opens desktop pane. + - Fresh spawn: pty-connection.ts calls + const gen = await pty:declarePendingPaneSerializer(paneKey) + BEFORE pty:spawn, then awaits both. Main mints a fresh gen and + records the pending intent under it. After spawn resolves with + ptyId, registerPaneSerializerFor(ptyId) + + pty:settlePaneSerializer(paneKey, gen) clear the intent (only if + the stored gen still matches). On spawn-rejection or unmount- + before-settle, pty:clearPendingPaneSerializer(paneKey, gen) runs + instead (mandatory — see "Renderer-side prerequisite" item 3). + - Reattach: registerPaneSerializerFor(ptyId) runs in + handleReattachResult once the ptyId is known. + Either way, ipc/pty.ts's cooperation gate sees that the renderer + is or will be authoritative for this PTY (via either + hasRendererSerializerFor(ptyId) === true OR a pre-signaled paneKey) + and SKIPS the daemon-snapshot seedHeadlessTerminal call. +T1 PTY emits live byte → ipc/pty.ts forwards to renderer (xterm grows) + → ipc/pty.ts forwards to runtime.onPtyData +T2 onPtyData: maybeHydrateHeadlessFromRenderer + - hasRendererSerializer(ptyId) === true → state: absent → pending + - serialize round-trip with opts {scrollbackRows, altScreenForcesZeroRows} + - on resolve: HeadlessEmulator built at rendered.cols/rows, + applySeededAgentStatus(ptyId, rendered.lastTitle), write(rendered.data) + - resize emulator to PTY dims if they diverge from rendered dims +T3 trackHeadlessTerminalData(data) chains AFTER the seed via writeChain, + so the live byte is appended on top of the seeded buffer — order + preserved. +T4 hydration state → done. Subsequent onPtyData calls skip the guard. +T5 user opens mobile → terminal.subscribe → serializeHeadless returns + seeded buffer + everything since. Mobile == desktop, and + leaf.lastAgentStatus is populated from rendered.lastTitle + (sourced from pane.terminal.onTitleChange, NOT from the seed payload — + SerializeAddon does not round-trip OSC titles). +``` + +### Nil path — Scenario A: no renderer ever mounts + +``` +T0 user relaunches Orca, opens mobile only. +T1 ipc/pty.ts: provider.spawn returns coldRestore.scrollback = "" + (daemon checkpoint had scrollbackAnsi: "" — visible screen only) + or a thin visible-screen snapshot. hasRendererSerializerFor(ptyId) + is false (no pane mounted), so the cooperation gate does NOT skip: + seedHeadlessTerminal runs as today. +T2 PTY emits live byte → onPtyData → maybeHydrateHeadlessFromRenderer + - hasRendererSerializer(ptyId) is false → state map left absent. + (Pane could still mount later in this runtime; we'd retry then.) +T3 trackHeadlessTerminalData populates from live bytes; emulator + already contains the daemon-snapshot seed. +T4 Mobile sees daemon's visible-screen snapshot + live bytes since. + KNOWN LIMITATION; out of scope for this design. +``` + +### Error path — renderer throws or unmounts mid-hydration + +``` +T0–T1 same as happy path through `pending` state. +T2 ptyController.serializeBuffer rejects (renderer disposed, + channel closed, IPC timeout, etc.). + - catch swallows the error. + - hydration state → done (we do not loop on every byte). +T3 Live bytes continue to populate the emulator via + trackHeadlessTerminalData (which lazily creates the headless + state at PTY dims). We lose the seed for this runtime but do + not block live data flow. +``` + +The "advance to done on serialize failure" rule is intentional. Re-trying on every subsequent byte would create a renderer round-trip on every byte until success — too costly for a best-effort path. The "leave state absent when the renderer isn't ready yet" rule (the precondition checks before `pending`) is the inverse trade-off: those checks are cheap (Map lookups), so retrying them is fine. + +### SSH note + +SSH-backed PTYs flow through the same `runtime.onPtyData` entry point as local and daemon-backed PTYs (see `ipc/pty.ts` provider listener — all providers call `runtime.onPtyData(id, data, at)` uniformly). Hydration applies to SSH PTYs without special-casing: when a desktop pane is mounted on an SSH-backed terminal, its `registerPtySerializer` registration is identical to the local case, and the runtime sees `hasRendererSerializer(ptyId) === true` regardless of provider. The mobile + SSH + relaunch case (rare in practice) inherits Scenario A's known-limitation behavior. + +### Concurrent path — live data arrives during hydration + +The renderer round-trip is asynchronous; live `pty:data` will continue to arrive before `serializeBuffer` resolves. The pattern from `seedHeadlessTerminal` already handles this: every emulator write is appended to `state.writeChain`, so: + +``` +writeChain: Promise.resolve() + → seed write ← scheduled by maybeHydrateHeadlessFromRenderer + → live byte 1 ← scheduled by trackHeadlessTerminalData + → live byte 2 + → live byte N +``` + +`emulator.write` is invoked in chain order, preserving the byte sequence exactly as the wire delivered it. The seed always lands before the live bytes that triggered hydration, because the trigger logic schedules the seed write *before* it returns control to `onPtyData`, which then schedules the live write. + +## Performance + +`ptyController.serializeBuffer(ptyId)` does an IPC round-trip to the renderer where xterm's SerializeAddon runs synchronously over the buffer. Cost is roughly proportional to scrollback size: empirically ~50–200 ms for a full 50k-row buffer. + +The cost profile is materially different from the rejected priority-flip approach: + +| Approach | Renderer round-trip frequency | +|---|---| +| Priority flip (rejected) | Once per `terminal.subscribe` AND `terminal.resizeForClient` | +| Hydrate-on-first-touch (this) | Once per `(ptyId, runtime)` pair, ever | + +For a typical session — Orca starts, user opens desktop pane, agent runs for hours, user opens mobile periodically — this approach pays the IPC cost exactly once *for hydration* per `(ptyId, runtime)` pair. The existing renderer-fallback inside `serializeTerminalBufferFromAvailableState` is preserved — it can still hit the renderer on `terminal.read` / `terminal.subscribe` when the headless emulator returns null — but that path is the read-time fallback, not the hydration path. The priority-flip approach paid the IPC cost on every mobile re-subscribe (foreground/background, navigation between worktrees, phone-fit toggle). The "first byte after pane mount" trigger ties the hydration cost to a moment that is already doing IO work, so the latency is hidden inside the existing flow. + +There is no per-keystroke cost; `trackHeadlessTerminalData` is unchanged. + +## Reflow asymmetry + +Hydration happens at whatever cols the renderer is currently at — typically the desktop's natural size (e.g., 105 cols). When mobile later subscribes and triggers a 49-col phone-fit resize, the headless emulator (now seeded with 105-col data) reflows to 49 cols just like the renderer would. Both buffers reflow from the same starting content, so they stay in sync. + +This is a real improvement over the priority-flip proposal, which was sensitive to the order in which renderer and headless reflow completed across the IPC tick boundary. With hydrate-on-mount the seed has already landed before any phone-fit resize is even requested, so the reflow asymmetry concerns largely go away: at subscribe time both buffers contain the same content and respond to the same resize event in the same `handleMobileSubscribe` synchronous block. + +## Risks + +1. **Live data arrives during pending hydration.** Mitigated by chaining through `state.writeChain`, exactly as `seedHeadlessTerminal` already does. Order is preserved. + +2. **Renderer pane unmount mid-round-trip.** Caught by `try/catch`; the guard advances to `done` so we don't loop on every subsequent byte, and live writes continue via lazy emulator creation in `trackHeadlessTerminalData`. We lose the seed for that PTY, which is acceptable given how rare unmount-during-IPC is. + +3. **Wire-payload size on hydration.** Capped at 1000 rows from day one via the shared `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` constant. Alt-screen forces 0 rows. This matches the existing serialize path's behavior, so users see identical depth from both sources. The headless emulator's own 5,000-row `DEFAULT_SCROLLBACK` (in `HeadlessEmulator`) is comfortably above the 1k hydration cap; if a future change raises the hydration cap, `HeadlessEmulator`'s default must be bumped in lockstep or the seed will be silently truncated as it lands in the emulator. + +4. **Renderer xterm is *behind* the runtime.** Theoretically possible if `pty:data` is delivered to the runtime before the renderer (depends on dispatch order in `ipc/pty.ts`). The renderer's xterm uses an asynchronous `write(callback)`, so even when bytes are delivered the buffer may not yet contain them. In practice this is sub-millisecond and the seed will be at most one frame behind the live byte that triggered it — better than today's headless-only path which has none of those bytes. + +5. **Hydration races registerPty.** If `onPtyData` fires before `runtime.registerPty` has been called for this PTY, `getTerminalSize` may return null and we default to 80×24 for the post-seed PTY dims. The seed itself is constructed at the renderer's reported dims (see Pseudocode), so xterm reflow only happens on the post-seed `resize` call — at most one reflow, and only when PTY and renderer dims actually diverge. + +6. **PTY-size vs renderer-dim divergence (resize-after-seed reflow).** Under mobile-fit overrides, or in the brief window before a `pty:resize` lands on the main process, the renderer's xterm dims and the runtime's `ptySizes` entry can disagree. Constructing the headless emulator at PTY-size and then writing a seed captured at renderer-dim would force xterm to wrap/reflow the seed in the wrong column count — visible as broken line breaks or doubled prompts. Mitigation: build the emulator at `rendered.cols / rendered.rows`, then resize to PTY-size after the seed lands. See Pseudocode. **At extreme size deltas (e.g., 105→32), the seed-then-reflow path can produce minor visible divergence vs the renderer's own reflow** (xterm reflow is not strictly invertible across very different widths, and the headless emulator runs the reflow on already-laid-out cells rather than the original byte stream). This is an accepted limitation, not a correctness issue: the reflow result is still valid xterm output, just potentially line-broken slightly differently than what the desktop pane shows for the same scrollback. Mobile users have always seen a phone-fit reflow distinct from desktop's; this design does not change that. + +7. **Note: writes are flushed before serialize.** The renderer-side serializer (`pty-buffer-serializer.ts:registerPtySerializer`) flushes `pendingWritesRef` via `replayIntoTerminalAsync` before calling `pane.serializeAddon.serialize()`, so the seed faithfully includes hidden-pane buffered writes. No additional sequencing logic is needed in the runtime. + +8. **`pendingWritesRef` double-write window (severity-adjusted P2).** When a pane is hidden at the moment bytes arrive, the renderer-side `pendingWritesRef` buffers them while `runtime.onPtyData` simultaneously routes the same bytes into the headless emulator via `trackHeadlessTerminalData`. The renderer's serializer flushes `pendingWritesRef` before serializing, so the seed payload includes those bytes. If hydration runs after `trackHeadlessTerminalData` has already written the same bytes, the headless emulator ends up with a duplicated stretch. + + **Mitigation: the P0-A2 pre-signal handshake makes this rare in practice.** With pre-signal, hydration is the FIRST writer for a fresh-spawn-with-pre-signal — `trackHeadlessTerminalData`'s lazy-create check sees the emulator already exists (created at hydration time) and just appends, so live bytes between seed-fetch and seed-resolve are queued behind the seed via `state.writeChain`. The double-write only manifests in the narrow combo of: (a) reattach path, (b) hidden pane buffering live bytes into `pendingWritesRef`, AND (c) hydration runs after `trackHeadlessTerminalData` has already populated the headless emulator. The impact is bounded: at most one ~8 ms PTY-batch's worth of bytes can be duplicated, the duplicate is visible only in mobile/CLI scrollback (desktop renders from its own xterm which never duplicates), and a future shell prompt overwrites the area anyway. Document as a known limitation; not a correctness-blocking issue. If it becomes user-visible, the fix is to track a per-pty "live bytes seen since last serialize request" cursor on the renderer and trim the seed payload to bytes the renderer observed before hydration started — out of scope for this design. + +## Test plan + +### Unit (`orca-runtime.test.ts`) + +- Hydration runs on the *first* `onPtyData` for a `ptyId` when `hasRendererSerializer` is true and `serializeBuffer` returns non-empty data. Assert the emulator's serialized output contains both the seeded prefix and the live byte. +- Subsequent `onPtyData` calls do **not** re-trigger hydration after a successful run. Mock `serializeBuffer` and assert it was called exactly once across N `onPtyData` invocations for the same ptyId. +- "Exactly once" claim is qualified: the renderer round-trip happens once per `(ptyId, runtime)` for hydration. The existing `serializeTerminalBufferFromAvailableState` renderer-fallback (consulted only when the headless emulator returns null) is preserved and may still hit the renderer on `terminal.read` / `terminal.subscribe` paths. +- When `ptyController` is null (CLI / headless test runs), hydration leaves the state map untouched, headless emulator is created lazily as today, no IPC attempt. +- When `hasRendererSerializer(ptyId)` returns false on the first byte but true on a later byte, hydration **does** run on the later byte (state is left absent on idle, not consumed). Confirms the contract change for Open Question P2-2. +- When `ptyController.serializeBuffer` rejects, the guard advances to `done` (no infinite retry), the emulator is created lazily by `trackHeadlessTerminalData`, live bytes still write through. +- Live data arriving during pending hydration is queued behind the seed write. Schedule the seed-write as a long pending promise, fire several `onPtyData` calls, await `writeChain`, assert the final emulator content has the seed prefix followed by the live bytes in correct order. +- The 1000-row cap is applied to the renderer-sourced hydration: mock `serializeBuffer` to return a payload representing >1000 rows, assert the requested option bag includes `scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS`. +- Alt-screen flag is forwarded: assert the request includes `altScreenForcesZeroRows: true`. (The alt-screen branch is exercised in renderer-side serializer tests where it has access to a real xterm `buffer.active.type`.) +- **Status parity (lastTitle field):** mock `serializeBuffer` to return `{ data, cols, rows, lastTitle }` where `lastTitle` is a recognizable agent title. Fire `onPtyData`. Assert `leaf.lastAgentStatus` is one of `'idle' | 'working' | 'permission'` (whatever `detectAgentStatusFromTitle` returns for that title) after hydration and before any live OSC title bytes are observed. Conversely, when `lastTitle` is absent / undefined OR `detectAgentStatusFromTitle(lastTitle)` returns `null` (unrecognizable title), assert `leaf.lastAgentStatus` is left unchanged from its prior value (no overwrite). +- **Multi-PTY concurrent hydration:** fire `onPtyData` for ptyA and ptyB before either `serializeBuffer` resolves. Assert each PTY's hydration completes with its own payload (no cross-talk through the IPC response listener pool — each request has a unique `requestId`). +- **Re-entrant `onPtyData`:** when `agentDetector.onData` synchronously triggers another `onPtyData` for the same ptyId (or a different ptyId), confirm the hydration state machine still holds — no double-pending, no skipped seed, no out-of-order emulator writes. +- **Cooperation with `seedHeadlessTerminal`:** when a renderer serializer is registered for `ptyId`, the spawn-time call from `ipc/pty.ts` skips seeding (no daemon-snapshot bytes written to the emulator). When no serializer is registered, the daemon-snapshot path runs as today. +- **Fresh-spawn timing race (P0-A2 pre-signal handshake):** simulate the renderer pre-signaling intent for paneKey via `const gen = await pty:declarePendingPaneSerializer(paneKey)`, then call the `pty:spawn` handler with `args.env.ORCA_PANE_KEY === paneKey`. Assert `seedHeadlessTerminal` is **NOT** invoked even though `hasRendererSerializerFor(result.id)` is false at that moment (the renderer hasn't received the ptyId yet). The renderer mock then `registerPtySerializer(ptyId, …)` and `pty:settlePaneSerializer(paneKey, gen)`. Fire `onPtyData(ptyId, liveByte)`. Assert `maybeHydrateHeadlessFromRenderer` runs and the headless emulator is populated from the renderer's serialize payload, with the live byte appended after the seed via `writeChain`. +- **Pre-signal without settle (renderer crash):** simulate `pty:declarePendingPaneSerializer(paneKey)` followed by `pty:spawn` (gate skips daemon seed) but the renderer never calls `registerPtySerializer` or `settlePaneSerializer`. Fire `onPtyData`. Assert hydration's preconditions leave the state map absent, `trackHeadlessTerminalData` lazy-creates the emulator, and live bytes populate it. The pending-paneKey entry is dropped on the next paneKey teardown event. +- **Pre-signal preserved across paneKey reuse during teardown:** simulate (a) old PTY for paneKey K is exiting, its `paneKeyTeardownListener` is queued (captured at registration time with `gen1`). (b) Mount-2 calls `pty:declarePendingPaneSerializer(K)` and stores generation `gen2` (`gen2 > gen1`). (c) The queued teardown for the old PTY runs; the listener invokes `settlePending(K, gen1)` and SKIPS the delete because `pendingByPaneKey.get(K) === gen2`. (d) Mount-2 calls `pty:spawn`; the cooperation gate matches (`pendingByPaneKey.has(K) === true`) and seed is suppressed. Assert daemon-snapshot seed did NOT run. +- **Test imports for `scrollback-limits.ts`:** tests in `orca-runtime.test.ts` and `mobile-subscribe-integration.test.ts` import `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` from `../scrollback-limits` so cap-assertion lines match the production constant (no hardcoded `1000`). +- **Status parity (real `SerializeAddon` round-trip):** in a renderer-side unit test (using a real `xterm` `Terminal` + `SerializeAddon`), write OSC 0/1/2 title bytes (e.g., `\x1b]0;some-title\x07`) into the terminal, await the `onTitleChange` listener, then call `serializeAddon.serialize()`. **Assert the serialize output does NOT contain the OSC title bytes** — this codifies the limitation that motivated the `lastTitle` field. Then, in a runtime-side unit test, mock the renderer-IPC response to include `{ data, cols, rows, lastTitle }` where `lastTitle` is a known agent title, fire `onPtyData`, and assert `applySeededAgentStatus` was called with that title and `leaf.lastAgentStatus` is the corresponding `'idle' | 'working' | 'permission'` value returned by `detectAgentStatusFromTitle` afterward. +- **`applySeededAgentStatus` does not resolve waiters:** with a leaf that has pending TUI-idle waiters and pending orchestration messages registered, call `applySeededAgentStatus(ptyId, title)` where `title` is a recognizable agent title. Assert `leaf.lastAgentStatus` matches `detectAgentStatusFromTitle(title)` (one of `'idle' | 'working' | 'permission'`), AND assert `resolveTuiIdleWaiters` and `deliverPendingMessages` were **NOT** invoked. (Spy on the methods or assert the waiter / pending-message queues are unchanged.) This guards against future drift where a refactor lifts shared logic between the live and seeded paths. + +### Integration (`mobile-subscribe-integration.test.ts`) + +- Cross-process scenario with a mocked renderer that registers a richer scrollback than the headless adapter would produce. After the runtime sees its first live byte, mobile subscribes; assert the `scrollback` frame's `serialized` field includes the seeded prefix. +- Scenario A regression guard: no renderer registered, mobile subscribes, assert mobile receives only what the daemon snapshot + live bytes provide, hydration was never invoked. (Documents the known limitation.) + +### Manual + +On a real device, reproduce the original `edit-issues` post-claude-exit scenario, confirm the mobile WebView now shows the same agent summary the desktop shows. + +## Open questions + +- **Hydration trigger point.** Three plausible options: (a) first `onPtyData`, (b) `runtime.registerPty`, (c) an explicit renderer-pane-mount notification from the renderer. (a) is the chosen approach: cheap, ties cost to a moment already paying IO, and **retryable within a runtime**. The state map is left absent (not `done` or `skipped`) when the renderer serializer isn't yet registered, so a later byte can attempt hydration once the pane mounts. The only consume-on-idle case is `headlessTerminals.has(ptyId)` (live bytes already arrived), where re-seeding would duplicate. (b) fires before the renderer has any data to serialize. (c) needs a new wire from `pty-connection.ts → ipc/pty.ts → runtime`. + +- **Cooperation with `seedHeadlessTerminal`.** Today `seedHeadlessTerminal` writes to the headless emulator on `provider.spawn` from `result.snapshot` / `result.coldRestore.scrollback`. The daemon's `result.snapshot` is the visible-screen-only ANSI from `checkpoint.json` — almost always non-empty (it includes the bare prompt) but typically much thinner than what the renderer pane has. If both fire for the same PTY, the daemon path lands first and `headlessTerminals.has(ptyId)` becomes true; the hydration guard would then mark itself `done` and the richer renderer payload is lost. **Decision: gate `seedHeadlessTerminal` on (a) an already-registered renderer serializer OR (b) a renderer pre-signal for the spawning PTY's paneKey.** In `ipc/pty.ts`, before calling `runtime.seedHeadlessTerminal(...)`, check both `runtime.hasRendererSerializerFor(result.id)` AND `pendingByPaneKey.has(args.env?.ORCA_PANE_KEY)` (after validating the paneKey shape — see P1-B2 in the cooperation gate snippet). If either is true, skip the daemon-snapshot seed entirely and let renderer hydration be canonical. (a) covers reattach, where the pane mounted before spawn returned. (b) covers fresh spawn, where the renderer pre-signaled intent before `pty:spawn` but cannot have registered yet because it doesn't know the ptyId. If neither is true (Scenario A — mobile-only, no desktop pane), the daemon-snapshot path runs unchanged. See "Renderer pre-signal handshake" for the full sequence and correctness properties. This is cleaner than a length-comparison "richness heuristic" and lets the renderer be authoritative whenever it's mounted *or* about to mount. + +## Rollout + +Single change touching the runtime and the renderer-side serialize controller. No feature flag — the hydration is best-effort and degrades to today's behavior when the renderer cannot answer or has nothing useful to share. + +Ready for implementation review. Scenario A remains a known limitation tracked for a future daemon-persistence design. diff --git a/docs/mobile-presence-lock.md b/docs/mobile-presence-lock.md new file mode 100644 index 00000000000..12584a51dab --- /dev/null +++ b/docs/mobile-presence-lock.md @@ -0,0 +1,690 @@ +# Mobile Presence Lock for Desktop Terminal + +Design doc for repurposing the existing "phone-fit" banner into a general +presence-based interaction lock between the desktop renderer and mobile +clients sharing a single PTY. + +## Problem + +The desktop and mobile clients share full read/write access to the same +underlying PTY. There is no synchronization between desktop's xterm `onData` +(which calls `provider.write(ptyId, bytes)` via the `pty:write` IPC) and +mobile's `terminal.send` RPC (which also calls `provider.write` through the +runtime). When both parties type at the same time, bytes interleave at the +TTY level — `ls` and `pwd` typed concurrently can produce +`lpwsd` and execute unintended commands. + +The same is true for resize: desktop `pty:resize` and mobile +`terminal.resizeForClient` both reach `provider.resize(ptyId, ...)` without +coordination. The renderer-side `safeFit` has a partial guard, but the +`pty:resize` IPC handler does not check the override server-side. + +A teammate using the original mobile build reported "odd behavior using +laptop + mobile at the same time." The most visible symptom is the desktop +terminal jumping to phone dimensions; the more dangerous symptom is silent +input interleaving. + +## Today's banner + +The existing banner in `TerminalPane.tsx` is keyed on +`getFitOverrideForPty(ptyId)`: + +- It only appears when mobile is in `auto`/`phone` display mode AND a phone + resize actually happened. +- It says "Terminal resized for phone (W×H)" with a "Restore" button that + calls `runtime:restoreTerminalFit(ptyId)`. +- That IPC handler sets the display mode to `desktop` and applies the + display mode, which resizes the PTY back to desktop dims and clears the + override. The banner unmounts. + +In `desktop` display mode (mobile is subscribed but viewing at desktop dims) +there is no override and no banner — the desktop user has no idea mobile is +watching. + +## Goal + +Repurpose the banner into a general presence-based lock with this single +rule: + +> At any moment, each PTY has exactly one *driver* — desktop, a specific +> mobile client, or nobody. While mobile drives, desktop input and resize +> are dropped; the desktop banner explains why and offers **Take back** to +> hand the floor over. While desktop drives, mobile keystrokes silently +> reclaim the floor (no mobile-side banner — see "Asymmetric UX" below). + +The lock exists at two layers: + +- **Renderer guard** (primary UX): `pty-connection.ts` drops xterm `onData` + and `onResize` while a mobile client is the driver. Banner explains why. +- **Server-side defense** (defense-in-depth): the `pty:write` and + `pty:resize` IPC handlers consult the runtime's driver state and drop + desktop-side calls while a mobile client is the driver. + +## Behavioral model + +### Driver state machine + +Instead of a binary lock, each PTY carries a tagged driver state. There is +exactly one driver per PTY at any moment, and transitions are atomic +(updated and emitted together from the runtime). + +```ts +type DriverState = + | { kind: 'idle' } + | { kind: 'desktop' } + | { kind: 'mobile'; clientId: string } +``` + +- `idle` — no mobile subscribers; desktop input and resize flow through + normally. (We don't bother distinguishing "no one is here" from "only + desktop is here" since they behave identically; `idle` is the catch-all + for "nothing is currently locking the desktop out.") +- `desktop` — at least one mobile client is subscribed but the desktop has + reclaimed the floor; desktop input and resize flow through. +- `mobile{clientId}` — `clientId` is the mobile actor that most recently + drove this PTY. The banner is mounted on desktop; desktop input and + resize are dropped. + +Invariants: + +- `currentDriver(ptyId)` is always exactly one of the three kinds. +- On the main process, a transition mutates `currentDriver(ptyId)` and + emits `terminalDriverChanged(ptyId, driver)` in the same critical + section. The renderer's mirror in `mobile-driver-state.ts` is updated + only when the IPC arrives — there is a brief IPC-hop window during + which the renderer's `getDriverForPty(ptyId)` returns a stale value. + This is why the server-side `pty:write` / `pty:resize` defenses are + **load-bearing, not redundant**: a desktop keystroke fired during + that window passes the renderer guard and is dropped by the server + guard. +- Only one driver per PTY at any moment. Multiple mobile clients can + *subscribe* simultaneously (see "Multi-mobile subscriber model"), but + only the most recent mobile actor is the driver. + +### Transitions + +| Current driver | Trigger | Next driver | Side effect | +|---|---|---|---| +| `idle` | mobile subscribes with `displayMode='auto'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims | +| `idle` | mobile subscribes with `displayMode='desktop'` (first client for this ptyId) | `desktop` | inner subscriber map populated; **no** banner; PTY stays at desktop dims | +| `idle` | desktop input or first PTY data after no subscribers | `idle` (no transition) | — | +| `mobile{A}` | desktop clicks **Take back** | `desktop` | banner unmounts; PTY snaps to desktop dims if at phone dims | +| `mobile{A}` | mobile A sends input/resize/setDisplayMode | `mobile{A}` (no transition) | — | +| `mobile{A}` | mobile B sends input | `mobile{B}` | (no banner change; both are "mobile") | +| `mobile{A}` | last mobile client unsubscribes | `idle` | banner unmounts | +| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto | +| `desktop` | mobile sets `displayMode` to `auto` or `phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture) | +| `desktop` | mobile sets `displayMode` to `desktop` | `desktop` (no transition) | — (already desktop-mode watching) | +| `desktop` | mobile subscribes-fresh with `auto`/`phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims | +| `desktop` | mobile subscribes-fresh with `desktop` | `desktop` (no transition) | inner map updated; no banner | +| `desktop` | last mobile client unsubscribes | `idle` | (banner already unmounted) | + +**Subscribe-in-desktop-mode rule.** A mobile client subscribing in +`displayMode='desktop'` is treated as a passive watch, not a take-floor +gesture. The driver stays at `idle`/`desktop`, so the desktop user is not +interrupted. The instant that client (or any peer) sends input, sets the +display mode to `auto`/`phone`, or sends a resize, the runtime transitions +to `mobile{thatClient}` and the banner appears. This matches the rest of +the design: the lock engages on *interaction*, not on *presence*. + +The protocol is "first-mover wins until the other party acts." Desktop +clicks Take back, mobile types, desktop types again — banner ping-pongs as +each side acts. The `clientId` carried in `mobile{clientId}` is updated +each time a mobile actor takes the floor; this is the wire channel by +which the runtime knows *which* phone last drove (useful for the +forward-path coordinator described at the bottom of this doc, and for the +multi-mobile semantics described below). + +### Take back and PTY dimensions + +Take back has two sub-cases: + +1. **Mobile was in `phone`/`auto` mode (PTY at phone dims)**. Take back + resizes PTY back to desktop dims (existing behavior of + `applyMobileDisplayMode('desktop')` when `wasResizedToPhone` is true). +2. **Mobile was in `desktop` mode (PTY already at desktop dims)**. Take + back is a pure lock-flag flip; no resize. Today's + `applyMobileDisplayMode('desktop')` already short-circuits the resize in + this case. + +In the symmetric direction, mobile reclaim: + +1. **Mobile is in `phone`/`auto` mode but PTY is at desktop dims** (because + desktop just clicked Take back): mobile reclaim re-applies the phone + resize via `applyMobileDisplayMode(currentMode)`. +2. **Mobile is in `desktop` mode**: pure lock-flag flip; no resize. + +## End-user UX + +| Driver | Banner (desktop) | Desktop input | Desktop resize | +|---|---|---|---| +| `idle` | hidden | allowed | allowed | +| `mobile{*}` | "🔒 Mobile is driving this terminal — your input is paused. Click **Take back** to resume." | blocked at xterm.onData (silent drop) | blocked in renderer + dropped in `pty:resize` IPC server-side | +| `desktop` | hidden | allowed | allowed | + +Walkthroughs: + +**Phone connects while you're typing.** Banner pops in. PTY may resize to +phone dims (existing). Your next keystroke is dropped. Click Take back to +unlock and (if needed) restore desktop dims. + +**You click Take back.** Banner gone. You type freely. Mobile stays +connected; mobile sees the desktop-sized terminal because PTY snapped back +(or stayed at desktop dims if mobile was in desktop mode). + +**Mobile types something while you're reclaimed.** Banner reappears. Your +next keystroke is blocked. PTY may snap back to phone dims (if mobile is in +auto mode). + +**Mobile disconnects.** Banner gone permanently. Driver returns to `idle`. + +**Multiple panes.** Driver state is per-pane. Phone on pane A doesn't +affect pane B. + +**Text selection / scrollback / copy.** Always allowed. The lock is +keystroke and resize only. + +**Output continues to render.** While the banner is mounted, terminal +output streams to xterm normally — it is the mobile actor's bytes you +are seeing. Only desktop-side keystrokes and resize are dropped. This +matters because "your input is paused" can read ambiguously; output +flow is unaffected. + +## Asymmetric UX (accepted tradeoff) + +Mobile sees no banner in this PR. The mobile UI is visually unchanged: no +"Desktop is driving" indicator, no analog to the Take back affordance. +This is asymmetric and we are accepting it deliberately: + +- **Smaller mobile UI surface.** Mobile already has limited screen real + estate and a constrained component set; introducing a presence banner + there is a non-trivial design + i18n + dismissibility task that we want + to defer until the lock model has settled in production. +- **Faster ship.** Driver state machine + multi-mobile subscriber fix + + desktop banner is the smallest change that fixes the dangerous collision + (silent input interleaving). Adding a mobile-side banner doubles the UI + scope. +- **Mobile reclaim is naturally signaled.** When a mobile user types + while desktop drives, the runtime flips the driver to + `mobile{thatClient}`, the desktop banner remounts, and (if mobile is in + auto mode) the PTY snaps back to phone dims. The mobile user sees + the pane reflow and their keystrokes appear in the output stream. There + is no silent black-hole condition on mobile that a banner would + resolve. +- **Mobile keystrokes always reclaim implicitly.** The protocol on mobile + is "just type to take the floor." There is no mobile-side button to + surface; a banner with no actionable control is closer to noise than + signal. + +The flip side: a mobile user typing into a stale view *won't* be told +"hold on, desktop is driving" before their keystroke lands. They just take +the floor. We consider that acceptable because (a) they're explicitly +acting, and (b) the desktop user gets the warning side of the tradeoff +where the risk of unintended action is much higher. + +If usage data later shows mobile users frequently being surprised by +desktop activity, a mobile banner can be added without changing the wire +format — `terminalDriverChanged(ptyId, driver)` already carries everything +needed. + +## Architecture + +### Where the truth lives + +The `mobileSubscribers` map (re-keyed; see "Multi-mobile subscriber model" +below) plus a new `currentDriver: Map` on the runtime +are the source of truth. The renderer learns the driver state via a new +IPC event `terminal-driver-changed(ptyId, driver: DriverState)` emitted +from the runtime through the existing notifier path. The structured +payload is intentional: a binary `locked: boolean` would lose the +`clientId` we need for multi-mobile semantics today and for the unified +write coordinator on the forward path. + +The runtime exposes `getDriver(ptyId): DriverState`, replacing the old +`isLocked(ptyId): boolean`. Server-side `pty:write` / `pty:resize` +defenses check `runtime.getDriver(ptyId).kind === 'mobile'` to drop +desktop-side calls. + +A new renderer module `mobile-driver-state.ts` mirrors +`mobile-fit-overrides.ts`: keyed by ptyId, supports +`getDriverForPty(ptyId): DriverState` and a subscribe-style change +listener. The `TerminalPane` banner mounts when +`getDriverForPty(ptyId).kind === 'mobile'`, and `pty-connection`'s +`onData`/`onResize` guards drop input/resize under the same predicate. + +### Why not reuse `getFitOverrideForPty`? + +The fit override only fires when the PTY was actually resized +(`mode='auto'` and `wasResizedToPhone=true`). It misses the +desktop-mode case where mobile is subscribed but no resize happened. The +driver state is broader than the fit override. + +### Why a new IPC event vs extending `terminal-fit-override-changed`? + +Cleaner separation. The fit override is about dimensions; the driver +state is about ownership. They happen to overlap in the auto/phone +subcase, but conflating them long-term ties future work on either to the +other. + +### Why a structured payload vs `locked: boolean`? + +The runtime needs to know *which* mobile client most recently drove (for +multi-mobile semantics today and for the future write coordinator). A +structured `DriverState` payload makes that information first-class on +the wire instead of smuggling it through a side channel. The cost is ~70 +LoC over a binary lock; the payoff is a symmetric UX foundation and a +future-proof wire format. + +## Multi-mobile subscriber model + +The `mobileSubscribers` map is today shaped `Map` and +each `terminal.subscribe` call overwrites the previous subscriber for that +ptyId. That overwrite is fine for a binary `has(ptyId)` presence check but +breaks the moment we treat presence as a real set: phone B subscribing +silently evicts phone A, so phone A unsubscribing or its WS dropping looks +like "no mobile clients" even while phone B is still on the line. The +driver state machine sits directly on top of presence, so soundness here +is a prerequisite, not a polish. + +We rekey to `Map>` and update each +callsite: + +- **`handleMobileSubscribe(ptyId, clientId, subscriber)`** — get-or-create + the inner map, then `inner.set(clientId, subscriber)`. Do not overwrite + peer clients. If the inner map was empty before insert, this is the + first subscriber for the PTY; emit the `idle → mobile{clientId}` driver + transition. +- **`handleMobileUnsubscribe(ptyId, clientId)`** — `inner.delete(clientId)`, + then if the inner map is now empty, delete the outer entry, run the + existing restore-resize logic, and emit the `* → idle` driver + transition. If the inner map is non-empty, do **not** fire restore / + driver-change; peers still have the floor. +- **`applyMobileDisplayMode(ptyId)`** — iterate the inner map. The + restore-resize semantics need a single representative subscriber (the + desktop dims to restore *to* are stored on the subscriber record). Pick + the earliest by subscribe time so the restore target is stable as + later phones come and go. The display mode itself is per-PTY runtime + state, not per-client. + + **Active phone-fit dim selection (multi-mobile).** When two or more + mobile clients subscribe with different viewports (e.g., iPhone + + iPad), the PTY can only be at one phone-fit size at a time. Rule: + **the most recent mobile actor's viewport wins**. This matches the + driver state machine — whoever last took the floor (`mobile{X}`) + also dictates the active phone-fit dims. When that client + unsubscribes, the next-most-recent surviving subscriber's viewport + wins; on the last client leaving, the inner map empties and we run + the existing restore back to the earliest-recorded desktop dims. We + do not use `min(cols, rows)` across subscribers because that + produces no clear principal — multiple TUIs are forced to render + for an aggregate viewport that nobody actually has, which is worse + than picking a single owner. Most-recent-actor is consistent with + the rest of the protocol (whoever acted last has the floor) and is + cheap to reason about. + + **Subscriber record extension.** "Most-recent actor" and + "earliest-by-subscribe-time" both require timestamps that today's + Subscriber record does not carry. Extend it with two fields: + + ```ts + interface Subscriber { + clientId: string + viewport: { cols: number; rows: number } + wasResizedToPhone: boolean + previousCols: number | null + previousRows: number | null + subscribedAt: number // ms since epoch, set on insert + lastActedAt: number // ms since epoch, init = subscribedAt; + // updated on every mobileTookFloor for this client + } + ``` + + - `applyMobileDisplayMode(ptyId)` for active phone-fit dims: + iterate the inner map, pick `argmax(lastActedAt)`, use that + subscriber's `viewport`. + - For restore-to-desktop semantics on last-client-leaves: pick + `argmin(subscribedAt)` **among subscribers with non-null + `previousCols`/`previousRows`** and use that subscriber's + `previousCols/Rows` as the restore target. Desktop-mode + subscribers carry `previousCols/Rows = null` by design (the + existing `handleMobileSubscribe` short-circuits dim capture for + desktop-mode joins to avoid capturing a stale full-width size), + so they are not viable restore targets. If no surviving + subscriber has captured dims, fall back to + `lastRendererSizes.get(ptyId)` (the desktop renderer's most + recent reported size) — same fallback the existing first-insert + capture path uses. + + Both operations are O(n) over the inner map, which is bounded by + the number of concurrently subscribed mobile clients (typically 1, + rarely 2-3). No indexing needed. +- **`isMobileSubscriberActive(ptyId)`** — returns `true` iff the inner map + is non-empty. +- **Driver state for the mobile side** stores the `clientId` of the most + recent mobile actor. Whenever any mobile client sends + input/resize/setDisplayMode/subscribes-fresh, that client becomes the + `currentDriver` (`mobile{thatClient}`). Phone B taking over from phone A + is an internal mobile→mobile transition: no banner change, but the + recorded `clientId` updates so the runtime always knows who is the + authoritative mobile actor. + +The semantic upshot is that the desktop banner is governed by "is the +inner map non-empty AND has the desktop not reclaimed?" rather than the +old single-slot heuristic, and Take back / mobile-reclaim correctly +ping-pong even when two phones are on the same PTY. + +## Implementation + +Files touched: + +1. `src/main/runtime/orca-runtime.ts` — rekey `mobileSubscribers` to + `Map>`, add `currentDriver` map, + `getDriver(ptyId)` getter, transition methods, notifier hook. Update + `handleMobileSubscribe` / `handleMobileUnsubscribe` / + `applyMobileDisplayMode` / `isMobileSubscriberActive` per the + multi-mobile model above. **Extend `onPtyExit` cleanup** to + `currentDriver.delete(ptyId)` and emit `terminalDriverChanged(ptyId, + { kind: 'idle' })` so any banner mounted on the dead pane unmounts. + Without this, a dead PTY's last driver state lingers and the + renderer banner could persist on a dead pane until tab teardown. +2. `src/main/runtime/orca-runtime.ts` interface `RuntimeNotifier` — add + `terminalDriverChanged(ptyId, driver: DriverState)`. +3. `src/main/window-manager.ts` (or wherever `RuntimeNotifier` is wired) — + forward to renderer via `webContents.send('terminal-driver-changed', …)`. +4. `src/preload/index.ts` + `src/preload/api-types.ts` — expose the new + event with the structured `DriverState` payload. +5. `src/main/ipc/runtime.ts` — add `runtime:reclaimTerminal` IPC (or + extend `runtime:restoreTerminalFit`) which drives the + `mobile{*} → desktop` transition. +6. `src/main/runtime/rpc/methods/terminal.ts` — drive the + `* → mobile{clientId}` transition in `terminal.send`, + `terminal.resizeForClient`, `terminal.setDisplayMode`, and the + subscribe handler (with the subscribe-in-desktop-mode exception + above). + + **Wire-format changes required.** The driver state machine tags + the active mobile actor with `clientId`, so every mobile RPC method + that can take the floor must carry the caller's identity. Today + only `terminal.subscribe` and `terminal.resizeForClient` do; we + extend the others. + + **Canonical identity shape.** Use the existing + `terminal.subscribe` shape: `client: { id: string; type: 'mobile' + \| 'desktop' }` (with `type` optional). `TerminalResizeForClient` + keeps its grandfathered top-level `clientId: string` field for + backward compatibility — implementers may add an aliased nested + `client.id` setter for consistency at the call site, but the wire + shape is preserved. + + New fields on existing schemas: + + - `TerminalSend` gains `client: { id: string; type?: 'mobile' \| + 'desktop' }` (optional for backward compatibility — falls back + to "the most recent mobile actor" when absent). + - `TerminalSetDisplayMode` gains the same `client` field. + - `TerminalUnsubscribe` gains `client: { id: string }` so the + server can derive the composite cleanup key (see below); the + existing `subscriptionId` field is kept and remains the primary + lookup, with `client.id` used only when the caller passed a + bare-handle `subscriptionId`. + + **Subscribe-side composite key.** Change `subscriptionId` from + `params.terminal` to `${params.terminal}:${params.client.id}` so + two phones subscribing to the same terminal handle do not evict + each other via `registerSubscriptionCleanup`. Without this fix, + the multi-mobile rekey to `Map>` + would be silently defeated at the RPC subscription layer (phone + B's subscribe runs phone A's cleanup → A's data listener tears + down → A's `handleMobileUnsubscribe` fires). + + **Unsubscribe-side composite key.** The mobile RPC client today + emits `terminal.unsubscribe` with `params: { subscriptionId: + stream.params.terminal }` — the bare handle. With the subscribe + side now keying by composite, a bare-handle unsubscribe will miss + in `subscriptionCleanups.get(bareHandle)` and silently no-op, + leaking the data listener and leaving `mobileSubscribers` + populated forever (banner stuck, driver never returns to `idle`). + Fix: the mobile RPC client emits `{ subscriptionId: + ${terminal}:${clientId}, client: { id: clientId } }`, and the + server's `cleanupSubscription` uses whichever the caller sent — + if both `subscriptionId` and `client.id` are present and the + caller passed only `terminal` in `subscriptionId`, the server + reconstructs the composite key from `client.id`. Belt-and-braces + so a stale older mobile build still cleans up correctly. + + This is a coordinated client/server wire-format change. The + mobile app must ship in lockstep; if a stale mobile client emits + bare-handle unsubscribe to a new server, the server's + reconstruction path catches it. If a new mobile client emits + composite-handle unsubscribe to a stale server, the stale server + ignores `client.id` and the composite key fails to match — but + that direction is moot because the stale server has not adopted + composite keys on subscribe either. No client newer than server, + so this is safe. +7. `src/main/ipc/pty.ts` — defense in depth: drop `pty:write` and + `pty:resize` calls when + `runtime.getDriver(id).kind === 'mobile'`. **Preserve the existing + `runtime.isResizeSuppressed()` short-circuit at the top of the + `pty:resize` handler** — the new driver-state guard is *in + addition to*, not in place of, the suppression window. The two + guards have different purposes: `isResizeSuppressed()` blocks the + safeFit cascade after a take-back transition (preventing + collateral resize corruption of background panes), while the + driver-state check blocks desktop-side resizes whenever mobile is + driving. Both must apply. +8. `src/renderer/src/lib/pane-manager/mobile-driver-state.ts` — new + renderer store mirroring `mobile-fit-overrides.ts`. Exposes + `getDriverForPty(ptyId): DriverState` and a change listener. +9. `src/renderer/src/components/terminal-pane/pty-connection.ts` — guard + `onData` and `onResize` on + `getDriverForPty(ptyId).kind === 'mobile'`. +10. `src/renderer/src/components/terminal-pane/TerminalPane.tsx` — switch + banner to consume driver state, update copy, rename Restore → Take + back. + +### Server-side detail + +```ts +// orca-runtime.ts +private currentDriver = new Map() + +getDriver(ptyId: string): DriverState { + return this.currentDriver.get(ptyId) ?? { kind: 'idle' } +} + +private setDriver(ptyId: string, next: DriverState): void { + this.currentDriver.set(ptyId, next) + this.notifier?.terminalDriverChanged(ptyId, next) +} + +reclaimTerminalForDesktop(ptyId: string): void { + if (!this.isMobileSubscriberActive(ptyId)) return + // Snap PTY back to desktop dims if currently at phone dims. + // applyMobileDisplayMode is a no-op resize when already at desktop dims. + this.setMobileDisplayMode(ptyId, 'desktop') + this.applyMobileDisplayMode(ptyId) + this.setDriver(ptyId, { kind: 'desktop' }) +} + +private mobileTookFloor(ptyId: string, clientId: string): void { + // If mobile is in phone/auto mode, re-apply phone resize on the first + // hand-off back from desktop. Mobile-to-mobile hand-offs are no-ops + // for resize. + const prev = this.getDriver(ptyId) + if (prev.kind === 'desktop') this.applyMobileDisplayMode(ptyId) + this.setDriver(ptyId, { kind: 'mobile', clientId }) +} + +handleMobileUnsubscribe(ptyId: string, clientId: string) { + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) return + inner.delete(clientId) + if (inner.size > 0) return // peer mobile clients still drive + this.mobileSubscribers.delete(ptyId) + // Drive transition fires synchronously so the desktop banner + // unmounts immediately. The PTY restore runs inside the existing + // 300ms pendingRestoreTimers debounce — see "Restore-debounce + // window" under Edge cases. + this.setDriver(ptyId, { kind: 'idle' }) + // Existing restore-resize logic (300ms debounced timer) runs here. +} +``` + +The mobile-took-floor path is invoked from the RPC method handlers (not +from `sendTerminal` directly) so that the driver flip is bound to +deliberate mobile actions, not to internal runtime calls. + +### Renderer guards + +```ts +// pty-connection.ts onData +if (currentPtyId && getDriverForPty(currentPtyId).kind === 'mobile') { + return // Mobile is driving this PTY; banner explains. +} +// existing transport.sendInput(data) + +// pty-connection.ts onResize +// Why: keep both predicates. getFitOverrideForPty fires synchronously +// in the renderer the moment safeFit runs; getDriverForPty arrives via +// IPC and lags by one round-trip. Removing the fit-override predicate +// would re-introduce the resize-over-mobile-fit bug mobile-fit-overrides +// was added to prevent. +if ( + currentPtyId && + (getFitOverrideForPty(currentPtyId) || + getDriverForPty(currentPtyId).kind === 'mobile') +) { + return +} +``` + +### Banner copy + +``` +🔒 Mobile is driving this terminal — your input is paused. [ Take back ] +``` + +When PTY is at phone dims, append the dim suffix: +``` +🔒 Mobile is driving this terminal (80×24) — your input is paused. [ Take back ] +``` + +The phrasing makes Take back the discoverable resolution. There is no +auto-unlock-on-WS-silence and no liveness probing; the banner stays up +until the desktop user takes back, until mobile actor sends input (which +keeps mobile as driver), or until the last mobile subscriber disconnects. + +## Edge cases + +- **Mobile drops network**. WS doesn't immediately know. The inner + subscriber map retains the entry for ~30s until ping timeout. Banner + stays. Take back still works during this window — it transitions to + `desktop` immediately. When mobile eventually reconnects with + `terminal.subscribe`, that client takes the floor again + (`desktop → mobile{thatClient}`). +- **Two phones on same PTY**. The inner map carries both subscribers. + Driver state is `mobile{whicheverActedLast}`. Phone A unsubscribing + while phone B is still on does *not* drop the banner; only the last + client leaving the inner map transitions to `idle`. +- **Pre-locked input in flight**. `transport.sendInput` is + fire-and-forget; there is no in-flight queue to drain. The first + dropped keystroke is on the renderer side, which is good enough. +- **Take back while phone is offline**. Works (transitions to `desktop`). + When phone reconnects with a fresh subscribe, driver flips to + `mobile{thatClient}`. +- **Display mode toggle from mobile**. `terminal.setDisplayMode` is + treated as mobile interaction → that client takes the floor. Aligns + with "any deliberate mobile action takes the floor." +- **Subscribe from a fresh client (reconnect with new clientId)**. The + subscribe path runs the `desktop → mobile{thatClient}` (or + `idle → mobile{thatClient}`) transition. A mobile reconnect takes back + the floor from desktop — correct semantic since the user actively + reopened the mobile view. +- **Restore-debounce window on last-subscriber-leaves**. When the + last mobile subscriber unsubscribes, the driver transitions to + `idle` synchronously (banner unmounts immediately on desktop), but + the PTY-dim restore runs inside the existing 300ms + `pendingRestoreTimers` debounce. For up to 300ms after the banner + unmounts, the PTY remains at phone dims while desktop input flows + through. Desktop typing into a still-squished terminal is a brief + visual mismatch, not a correctness bug — the existing debounce is + there to absorb rapid tab switches without thrashing PTY size, and + emitting `idle` synchronously preserves that behavior. Do not + reorder these (driver-emit-first, then debounced restore) without + re-introducing thrash. + +## Tests + +- `orca-runtime.test.ts` (driver state machine): + - Every transition row in the table above has a unit test asserting + `(prev, trigger) → (next, side effect, emitted event payload)`. + - `getDriver` defaults to `{ kind: 'idle' }` for unknown ptyIds. + - `terminalDriverChanged` is emitted exactly once per state change and + carries the structured `DriverState` payload (not a boolean). +- `orca-runtime.test.ts` (multi-mobile subscriber sequencing): + - Phone A subscribes (`auto`) → driver is `mobile{A}`, banner emit + fires. + - Phone A subscribes (`desktop`) from `idle` → driver stays `desktop` + (subscribe-in-desktop-mode does not take the floor); inner map + has A. + - Phone B subscribes (`auto`) while A is still on (`auto`) → driver + flips to `mobile{B}` (subscribe-fresh-with-auto/phone counts as + take-floor); inner map has both subscribers; active phone-fit + dims switch to B's viewport. + - Phone B unsubscribes while A is still on → driver remains mobile; + active phone-fit dims revert to A's viewport (next-most-recent + actor); banner stays up; no `idle` transition. + - Phone A unsubscribes (last client leaves) → driver transitions to + `idle`, banner unmounts, PTY restores to earliest-recorded + desktop dims. + - `applyMobileDisplayMode` picks the **most-recent-actor's** + viewport for active phone-fit dims and the + **earliest-by-subscribe-time** desktop dims for restore. + - Mobile sets `displayMode` to `desktop` from `mobile{*}` does *not* + transition to `desktop` automatically (existing setDisplayMode + semantics stand) — but the runtime's *driver* transition rule for + `desktop → desktop` (mode change to desktop while already in + desktop driver) is a no-op. +- `terminal.test.ts` (subscriptionId per-client keying): + - Phone A subscribes to terminal handle `T`, phone B subscribes to + the same handle `T` → A's data listener still receives bytes + after B's subscribe (subscriptionId is `${T}:${clientId}` so + `registerSubscriptionCleanup` does not collide). + - Regression: prior to the fix, the second subscribe ran the first + subscribe's cleanup and tore down phone A's stream. +- `pty-connection.test.ts`: + - `onData` is dropped while `getDriverForPty(id).kind === 'mobile'`. + - `onData` is delivered when driver flips to `desktop` or `idle`. + - `onResize` is dropped while driver is `mobile`. +- `pty.test.ts` (or new `pty-driver-state.test.ts`): + - `pty:write` IPC is dropped when `runtime.getDriver(id).kind === + 'mobile'`. + - `pty:resize` IPC is dropped under the same predicate. + +## Out of scope + +- Mobile yanking desktop tab focus via `terminal.focus`. +- Cold-restore ack from mobile-only attach. +- Mobile-side "Desktop is driving" banner (see "Asymmetric UX" above). + +These are independent collisions tracked separately. + +## Forward path + +The natural next step (not in this PR) is unifying both writers — the +desktop renderer's `pty:write` IPC and the mobile RPC's `terminal.send` +— into a single coordinator that queues bytes through a runtime-owned +write path. The driver state machine cleanly supports this: the +coordinator can use `currentDriver(ptyId)` as the admission predicate +(only the current driver's bytes are dequeued), and the +`mobile{clientId}` payload tells it which queue head to drain when a +specific phone is the active actor. Today's PR keeps two independent +write paths and uses driver state purely as a drop-filter; the same +state machine becomes the scheduling input on the forward path without +a wire-format change. + +## Rollout + +Single PR. No feature flag needed; behavior is strictly additive (locks +an existing collision surface) and the existing banner UX continues to +work under the new driver-state model with sharper copy. diff --git a/docs/mobile-shared-client-per-host.md b/docs/mobile-shared-client-per-host.md new file mode 100644 index 00000000000..e9a8cee4d19 --- /dev/null +++ b/docs/mobile-shared-client-per-host.md @@ -0,0 +1,406 @@ +# Single Shared RPC Client per Host (Mobile) + +Design doc for collapsing the per-screen WebSocket connection model into a +single shared `RpcClient` per paired host, owned by a React context that +sits above the route tree. + +## Problem + +The mobile app today opens **one WebSocket per screen per host**: + +| Screen | Connections per host | +|---|---| +| Home (`app/index.tsx`) | 1 (with persistent `accounts.subscribe` stream) | +| Host detail (`app/h/[hostId]/index.tsx`) | +1 | +| Worktree session (`app/h/[hostId]/session/[worktreeId].tsx`) | +1 | +| Accounts (`app/h/[hostId]/accounts.tsx`) | +1 | +| Pair confirm (briefly) | +1 | + +A user actively browsing one host typically holds **3–4 simultaneous +sockets** to the desktop runtime. Each call to `connect()` runs its own +E2EE handshake, allocates an ephemeral keypair, and runs an independent +reconnect loop with exponential backoff. + +This causes three observable problems: + +1. **Stuck-connecting / reconnecting for minutes.** The desktop's + `MAX_WS_CONNECTIONS = 32` is shared across all clients. A user who + navigates rapidly accumulates stale sockets faster than they can be + reaped by TCP keepalive (which can take 60–300s on default systems + for half-open connections from a phone leaving Wi-Fi range or + backgrounding). Once the cap is hit, new sockets are rejected with WS + close code `1013 Maximum connections reached`. The mobile reconnect + loop does not recognize 1013 as terminal — it retries with backoff, + each retry also dropped, until enough stale sockets are reaped. + Result: a screen that should connect in <1s is stuck for 1–5 minutes. +2. **Tab create/delete hangs.** `client.sendRequest('terminal.create')` + awaits `waitForConnected()`. If the session-screen client is in + `connecting`/`reconnecting` state because its socket lost the cap + race, the await blocks until the 30s `REQUEST_TIMEOUT_MS` fires. + The user sees nothing happen. +3. **Triple cost on every cold-start.** Three E2EE handshakes, + three Curve25519 keypair generations, three subscription + re-registrations on every app launch. On low-end Android, this + visibly delays first paint by hundreds of milliseconds. + +The architecture also wastes server resources: each socket carries its +own E2EE channel, its own subscription set, its own driver-state-machine +client identity (cf. `docs/mobile-presence-lock.md`). The server already +has logic to reconcile multi-socket-per-token tear-down +(`hasOtherConnections` in `ws-transport.ts`) — that logic exists *because* +this design forced the question; with a single client per host, it becomes +unnecessary. + +## Today's transport ownership + +Five files independently call `connect(endpoint, deviceToken, publicKeyB64)`: + +``` +mobile/app/pair-confirm.tsx # one-shot during pairing +mobile/app/pair-scan.tsx # one-shot during pairing +mobile/app/index.tsx # N (one per paired host) +mobile/app/h/[hostId]/index.tsx # 1 (host detail) +mobile/app/h/[hostId]/session/[worktreeId].tsx # 1 (session) +mobile/app/h/[hostId]/accounts.tsx # 1 (accounts) +``` + +Each owns a `useRef` and calls `client.close()` from a +cleanup function. The home screen additionally maintains a +`clientsRef: Array<{ hostId, client }>` so its own usage of the per-host +client survives across navigation events. + +This pattern works for *correctness* — every cleanup eventually closes +its socket — but it breaks under three real-world conditions: + +1. **Rapid navigation.** Mounts spawn before unmounts complete; cleanup + `client.close()` runs after a new screen has already opened a fresh + socket to the same host. Two sockets briefly coexist for the same + token, multiplied across screens. +2. **Network drops.** A backgrounded/locked phone on a flaky network + leaves sockets half-open. The server doesn't get a FIN; cleanup + relies on TCP keepalive timing. Meanwhile the foreground app, on + resume, opens fresh sockets. The half-open ones eat the cap until + reaped. +3. **Hot reload during dev.** Each Metro hot reload fires a new render + tree without unmounting the old, so connections leak. + +## Goal + +> A paired host has at most **one active WebSocket** at any time, owned +> by a context provider above the route tree. All screens for that host +> share that client. The pair flows are the only places that create +> short-lived clients (and they explicitly close those after pairing +> completes). + +This is the architectural fix to the symptoms above. Combined with the +two recently-shipped hotfixes (token cache + stable `useEffect` +dependency on home screen), this completes the connection-lifecycle +work. + +## Design + +### Layered ownership + +``` +RootLayout () +└── routes + └── // mounts when route has hostId + ├── h/[hostId]/ // host detail + ├── h/[hostId]/session/[worktreeId]/ // session + └── h/[hostId]/accounts/ // accounts +``` + +Two providers, layered: + +1. **`RpcClientProvider` (root)** — owns one `RpcClient` per host, + keyed by `hostId`. Lifecycle: opens on first request for that + host's client, holds open until app shutdown OR until the host is + removed (`removeHost(hostId)` triggers explicit close). Reuses + existing `loadHosts()` cache from the recently-merged + `host-store.ts` work. +2. **`HostScopedClientGate` (per host)** — a thin route-layout + component placed at `app/h/_layout.tsx`. Reads `hostId` from + route params, requests the client for that host from the root + provider, exposes it via context to descendants, and renders a + loading state until the client reaches `connected`. Guarantees + every descendant screen sees the same client instance for that + host — no per-screen `connect()` calls. + +The home screen (`app/index.tsx`) lives outside `HostScopedClientGate` +since it spans all hosts; it consumes the root provider directly via +a multi-host hook (see API below). + +### API + +```ts +// New file: mobile/src/transport/client-context.tsx +type RpcClientContext = { + // Get-or-open. Returns the singleton client for hostId; opens it + // lazily on first call, reuses it for all subsequent callers. Never + // returns null (returns a placeholder client in 'connecting' state + // if open hasn't completed). + getClient: (hostId: string) => RpcClient + // Connection state for a given host (driven by client.onStateChange). + useHostState: (hostId: string) => ConnectionState + // Useful for the home screen which renders all hosts at once. + useAllClients: () => Array<{ hostId: string; client: RpcClient }> +} + +export const RpcClientProvider: React.FC<{ children: React.ReactNode }> + +export const useHostClient: (hostId: string) => { + client: RpcClient + state: ConnectionState +} +``` + +Internal store (single `useRef` in the provider): + +```ts +type StoreEntry = { + client: RpcClient + state: ConnectionState + refCount: number // number of active screens holding this client + closeTimer: NodeJS.Timeout | null +} +const store = useRef(new Map()) +``` + +### Lifecycle rules + +1. **Open on first read.** First `getClient(hostId)` call for a host + reads the host record (uses cached `loadHosts()`), then calls + `connect()` and stores the client. Subsequent calls return the + cached entry. +2. **Idle close timer.** When `refCount` drops to 0 (all screens for + that host unmounted), schedule a 30-second close timer. If a screen + for the same host mounts within 30s, cancel the timer. Otherwise, + close the client and remove from the store. + - Why 30s: covers fast tab-switching and back-navigation without + keeping idle sockets forever. Tunable based on observed behavior. +3. **Forced close on host removal.** `removeHost(hostId)` from + `host-store.ts` calls into the provider to close the client + immediately and delete the store entry. +4. **App backgrounded.** No special action — let TCP keepalive and + server-side reaping handle it. Reconnect happens on foreground. +5. **App foregrounded.** Trigger a `getState()` poll on every + non-closed entry; if any are in `disconnected` (TCP died while + backgrounded), the existing reconnect loop handles it. No new + client allocations. + +### Public surface for screens + +Each screen replaces: + +```ts +// Before +const [client, setClient] = useState(null) +const [connState, setConnState] = useState('disconnected') + +useEffect(() => { + let rpcClient: RpcClient | null = null + void (async () => { + const hosts = await loadHosts() + const host = hosts.find((h) => h.id === hostId) + if (!host) return + rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState) + setClient(rpcClient) + })() + return () => { + rpcClient?.close() + } +}, [hostId]) +``` + +with: + +```ts +// After +const { client, state } = useHostClient(hostId) +``` + +Total LoC reduction across screens: ~150 lines. + +### Pair flow exception + +`app/pair-confirm.tsx` and `app/pair-scan.tsx` continue to call `connect()` +directly with **explicit `client.close()`** after the test request returns. +Reason: the host record doesn't yet exist in `loadHosts()` during pairing, +so the provider has nothing to look up. The pair flow's client is a +short-lived transient that delivers `getStatus()` once and then dies. + +After `saveHost()` succeeds, the user is navigated away; the next time +they enter `/h/[hostId]/...`, the provider opens a fresh client through +the normal path. + +### Streaming subscription handling + +The home screen's `accounts.subscribe` stream and the session screen's +terminal subscriptions remain owned by their respective screens — the +provider doesn't manage subscriptions, only the underlying transport. +Each screen's effect calls `client.subscribe(...)` and stores the +returned unsubscribe function. On unmount, the screen unsubscribes +(returns to the existing per-screen pattern, just over a shared +transport). The transport's `subscribe()` already correctly multiplexes +multiple listeners on one WebSocket via the `id` field. + +### State propagation + +`useHostState(hostId)` returns the live `ConnectionState`. The provider +maintains a per-host `useState` keyed by hostId; the `client.onStateChange` +listener is wired once at client creation and updates the corresponding +state slot. `useHostState` reads from this state via `useSyncExternalStore` +or a context selector — the choice is mostly preference; in this +codebase, given the small state shape, a simple `useContext + useMemo` +of the matching slot is fine. + +## Migration + +Step-by-step, each step independently shippable: + +1. **Add `RpcClientProvider` and `useHostClient`.** No callers yet. + Wire into `app/_layout.tsx`. Existing screens unchanged. +2. **Migrate session screen** (highest-risk, most-used). Replace + per-screen `connect()` with `useHostClient`. Test connection + behavior, terminal create/delete, scrollback hydration. +3. **Migrate host detail and accounts screens.** Same pattern. +4. **Migrate home screen.** Replace `clientsRef` with + `useAllClients()`. The home screen's per-host streaming + subscriptions move into a hook that runs per-host. +5. **Add `HostScopedClientGate`** at `app/h/_layout.tsx` to centralize + the gate and remove duplicated loading-state logic. +6. **Delete legacy code.** Remove the dead `connect()` import paths + from each screen. Codepoint reduction. +7. **Remove server-side `hasOtherConnections` complexity** in a + follow-up: with one socket per token, the multi-socket reconciliation + in `runtime-rpc.ts` `wsTransport.onConnectionClose` simplifies. + This is a desktop-side cleanup PR done after mobile rolls out. + +Each step is tested in isolation; rollback per step is trivial. + +## Risks + +### R1: Connection loss while screens are mounted + +**Risk.** Today, when a screen unmounts due to network loss, its +client closes and reopens on remount. Under the new design, a +network-loss-during-screen-mounted means the client lives but is in +`reconnecting` state. + +**Mitigation.** The existing `RpcClient` already handles this — its +internal reconnect loop runs invisibly. Screens already render based +on `connState === 'connected'`, so they stay in their `connecting` +UI until the loop succeeds. No regression. + +### R2: One bad host poisons the singleton + +**Risk.** If the client for one host is wedged in `reconnecting` due +to a desktop-side issue, all screens for that host inherit the wedged +state. Under the per-screen design, navigating to a different screen +gave a fresh client with a chance to connect cleanly. + +**Mitigation.** "Force reconnect" affordance: a button on the host +detail "Connection issues" UI calls +`provider.forceReconnect(hostId)` — close + reopen the client. Users +who hit a stuck state get a one-tap recovery without uninstalling. +Implemented as part of step 2. + +### R3: Idle close timer races + +**Risk.** A user navigates from session → home → back to session +within 35 seconds. The 30s idle timer fires between hops and closes +the client; the back-navigation has to wait for a fresh handshake. + +**Mitigation.** Cancel the timer at `getClient(hostId)` time, not at +mount time. As long as the consumer holds a reference to the client, +the timer is paused. Standard refcount pattern. + +### R4: Memory / state leak on rapid host removal + +**Risk.** User removes a host while a screen for it is mounted. +The screen's reference is now dangling. + +**Mitigation.** `removeHost(hostId)` triggers an explicit close + +delete from the store. Screens already handle `auth-failed` / +`disconnected` states (the client transitions to one of them on +forced close). Add a navigation-bounce in those states so the screen +returns to the host list. + +### R5: Pair-flow socket leaks through provider + +**Risk.** If pair-confirm crashes mid-handshake before its explicit +`close()`, the socket leaks. + +**Mitigation.** Independent of the provider — same risk exists today. +Add a try/finally + cleanup useEffect in pair-confirm. + +### R6: Provider re-initialization on hot reload (dev only) + +**Risk.** Metro hot reload re-runs `RpcClientProvider`, possibly +spawning new clients while old ones are still in the store. + +**Mitigation.** On provider mount, scan the store for entries whose +clients report `closed` state and prune. Acceptable dev-only friction. + +## Test Plan + +### Unit / hook tests + +- `useHostClient` returns the same client instance across multiple + consumers for the same hostId. +- `removeHost(hostId)` immediately closes the client (assert via + `client.getState()`). +- Idle close timer: zero refcount → 30s wait → client closed. +- Idle close timer cancellation: zero refcount → 15s wait → + consumer subscribes → no close. + +### Integration / manual + +- Create one host; navigate home → host detail → session → back ×10 + rapidly. Single socket on desktop (verify via desktop debug log). +- Background app for 5 min; foreground; verify reconnect uses same + client instance, no leak. +- Remove host while session screen mounted; screen bounces back to + home; client closed. +- Force-reconnect button on host detail; client closes and reopens + cleanly. +- Hot reload during development; no socket leak (verify desktop + active-connection count). + +### Regression + +- Terminal create/delete works during normal browse (Bug A from + initial reports — should never recur once cap pressure is removed). +- Scrollback hydration unchanged. +- Phone-fit / driver-lock state machine unchanged + (`docs/mobile-presence-lock.md` invariants hold). + +## Out of scope + +- **Desktop-side connection LRU eviction.** Useful as a defense-in-depth + but not needed once mobile self-limits to one socket per host. +- **Application-level ping/pong.** Worth adding but separate concern; + helps server reap dead sockets faster regardless of how many a single + client opens. +- **iOS share extension or system-wide deep-link integration.** Future + product work. + +## Effort estimate + +3–4 hours including tests and incremental migration. Each step +independently mergeable. + +## References + +- `docs/mobile-presence-lock.md` — driver-state-machine that depends + on per-client identity. Single-client-per-host simplifies but + doesn't break this contract. +- `docs/mobile-prefer-renderer-scrollback.md` — scrollback hydration + flow. Subscriptions remain per-screen; transport changes are + transparent. +- `mobile/src/transport/rpc-client.ts` — existing `connect()` + implementation; reconnect loop, E2EE handshake, subscription + multiplexing all preserved as-is. +- `src/main/runtime/rpc/ws-transport.ts` — server-side + `MAX_WS_CONNECTIONS = 32`, `hasOtherConnections` reconciliation + that becomes simpler post-migration. diff --git a/docs/performance-audit.md b/docs/performance-audit.md deleted file mode 100644 index a5f984cf241..00000000000 --- a/docs/performance-audit.md +++ /dev/null @@ -1,81 +0,0 @@ -# Orca Performance Audit - -Audit date: 2026-04-13 -Branch: `Jinwoo-H/performance-improvement` - ---- - -## Tier 1 — High Impact, Low Complexity - -| # | Area | Issue | File(s) | Status | -|---|------|-------|---------|--------| -| 1 | Renderer | `App.tsx` has 53 separate Zustand subscriptions — nearly every state change re-renders the root and cascades through the entire tree | `src/renderer/src/App.tsx:40-180` | DONE (53→22 subs, memo barriers on 4 children) | -| 2 | Renderer | `Terminal.tsx` has unscoped `useAppStore.subscribe()` — fires on every store mutation, does O(worktrees×tabs) scan each time | `src/renderer/src/components/Terminal.tsx:645-659` | DONE | -| 3 | Terminal | No IPC batching — every PTY data chunk is a separate `webContents.send` call, hundreds/sec under load | `src/main/ipc/pty.ts:173-177` | DONE | -| 4 | Terminal | Divider drag calls `fitAddon.fit()` on every `pointermove` pixel — xterm reflow can take 500ms+ with large scrollback | `src/renderer/src/lib/pane-manager/pane-divider.ts:90-119` | DONE | -| 5 | Main | Startup blocks: `openCodeHookService.start()` and `runtimeRpc.start()` awaited sequentially before window opens | `src/main/index.ts:131-168` | DONE | -| 6 | Main | `persistence.ts` uses `readFileSync`/`writeFileSync`/`renameSync` on the main thread — blocks during startup and every 300ms save | `src/main/persistence.ts:59-125` | DONE | -| 7 | Worktree | `worktrees:listAll` iterates repos sequentially with `await` — total time = sum of all repos instead of max | `src/main/ipc/worktrees.ts:53-84` | DONE | -| 8 | Browser | Reverse Map scan O(N) on every mouse/load/permission event in `BrowserManager` | `src/main/browser/browser-manager.ts:91-96` | DONE | -| 9 | Browser | `before-mouse-event` listener fires for ALL mouse events on ALL guests, even background ones | `src/main/browser/browser-guest-ui.ts:78-93` | DONE | -| 10 | Worktree | `refreshGitHubForWorktree` bypasses 5-min cache TTL on every worktree switch — fires GitHub API calls on rapid tab switching | `src/renderer/src/store/slices/worktrees.ts:467-489` | DONE | - ---- - -## Tier 2 — Medium Impact - -| # | Area | Issue | File(s) | Status | -|---|------|-------|---------|--------| -| 11 | Main | `git/repo.ts`: `execSync('gh api user ...')` and chains of 5 sync git processes block main thread | `src/main/git/repo.ts:87-138` | TODO | -| 12 | Main | `hooks.ts`: `readFileSync`/`writeFileSync`/`mkdirSync`/`gitExecFileSync` in IPC handlers | `src/main/hooks.ts:113-416` | TODO | -| 13 | Renderer | `useSettings` returns entire `GlobalSettings` (~30+ fields) — any setting change re-renders all consumers | `src/renderer/src/store/selectors.ts` | TODO | -| 14 | Renderer | Tab title changes bump `sortEpoch` for ALL worktrees → triggers WorktreeList re-sort on every PTY title event | `src/renderer/src/store/slices/terminals.ts:325` | TODO | -| 15 | Renderer | `CacheTimer`: one `setInterval` per mounted card (20 cards = 20 intervals/sec), each with O(n) selector work | `src/renderer/src/components/sidebar/CacheTimer.tsx:28-48` | TODO | -| 16 | Renderer | Three simultaneous 3-second polling intervals per active worktree (git status, worktrees, stale conflict) | `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts` | TODO | -| 17 | Renderer | 6 components missing `React.memo`: `SourceControl`, `RightSidebar`, `EditorPanel`, `ChecksPanel`, `FileExplorer`, `TabBar` | Various files in `src/renderer/src/components/` | DONE (8 components wrapped) | -| 18 | Worktree | Git polling fires immediately on every worktree switch (burst of `git status` + `git worktree list`) | `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts:69-103` | REVERTED (150ms debounce caused visible flash on switch) | -| 19 | Worktree | `FileExplorer` `dirCache` discarded on every worktree switch — re-fetches entire tree from scratch | `src/renderer/src/components/right-sidebar/useFileExplorerTree.ts:27,81-96` | TODO | -| 20 | Terminal | `pty:resize` uses `ipcRenderer.invoke` (round-trip) instead of fire-and-forget `send` | `src/preload/index.ts:203-205` | DONE | -| 21 | Terminal | Flow control watermarks defined but never enforced for local PTYs — unbounded output floods renderer | `src/main/providers/local-pty-provider.ts:330-332` | TODO | -| 22 | Browser | `BrowserPane` subscribes to entire `browserPagesByWorkspace` map — any tab's navigation re-renders all panes | `src/renderer/src/components/browser-pane/BrowserPane.tsx:312` | TODO | -| 23 | Browser | `findPage`/`findWorkspace` do O(N) `Object.values().flat().find()` scans on every navigation event | `src/renderer/src/store/slices/browser.ts:221-240` | TODO | -| 24 | Browser | Download progress IPC fires at full Chromium frequency (many/sec) | `src/main/browser/browser-manager.ts:421-430` | TODO | -| 25 | Main | `detectConflictOperation` runs 4 `existsSync` + `readFile` on every 3s poll before git status | `src/main/git/status.ts:236-265` | DONE | -| 26 | Main | `getBranchCompare`: `loadBranchChanges` and `countAheadCommits` run sequentially | `src/main/git/status.ts:374-375` | DONE | -| 27 | Main | `addWorktree` calls 4-5 synchronous git processes from IPC handler | `src/main/git/worktree.ts:116-157` | DONE | - ---- - -## Tier 3 — Lower Impact / Architectural - -| # | Area | Issue | File(s) | Status | -|---|------|-------|---------|--------| -| 28 | Terminal | SSH relay `FrameDecoder` uses `Buffer.concat` on every chunk (quadratic copy) | `src/relay/protocol.ts:84` | TODO | -| 29 | Terminal | SSH relay replay buffer uses string concatenation (quadratic allocation) | `src/relay/pty-handler.ts:68-72` | TODO | -| 30 | Terminal | `extractLastOscTitle` regex runs on every PTY chunk with no fast-path bail | `src/shared/agent-detection.ts:23-39` | TODO | -| 31 | Terminal | Binary search calls `serialize()` up to 16× at shutdown | `src/renderer/src/components/terminal-pane/TerminalPane.tsx:579-596` | TODO | -| 32 | Browser | `capturePage()` captures full viewport then crops — should pass rect directly | `src/main/browser/browser-grab-screenshot.ts:36` | TODO | -| 33 | Browser | Parked webviews retain full 100vw×100vh compositor surfaces | `src/renderer/src/components/browser-pane/BrowserPane.tsx:94-107` | TODO | -| 34 | Browser | `onBeforeSendHeaders` intercepts every HTTPS request even when UA override unused | `src/main/browser/browser-session-registry.ts:126-137` | TODO | -| 35 | Main | `setBackgroundThrottling(false)` wastes CPU when window is minimized | `src/main/window/createMainWindow.ts:97` | TODO | -| 36 | Main | `warmSystemFontFamilies()` competes with startup I/O | `src/main/system-fonts.ts:30-32` | TODO | -| 37 | Renderer | Session persistence effect has 15 deps — fires on every tab title change | `src/renderer/src/App.tsx:239-283` | TODO | -| 38 | Renderer | Per-card `fetchPRForBranch` on mount — 30 worktrees = 30 simultaneous IPC calls | `src/renderer/src/components/sidebar/WorktreeCard.tsx:128-132` | TODO | -| 39 | Renderer | Synchronous full `monaco-editor` import blocks EditorPanel chunk evaluation | `src/renderer/src/components/editor/EditorPanel.tsx:7` | TODO | -| 40 | Worktree | `removeWorktree` runs `git worktree list` twice (pre and post removal) | `src/main/git/worktree.ts:163-208` | TODO | -| 41 | Worktree | `worktrees:list` can trigger duplicate `git worktree list` when cache is dirty | `src/main/ipc/worktrees.ts:86-116` | TODO | -| 42 | Renderer | SSH targets initialized sequentially in a `for...await` loop | `src/renderer/src/hooks/useIpcEvents.ts:232-252` | TODO | - ---- - -## Key Themes - -1. **Zustand subscription granularity** — `App.tsx` subscribes to 53 slices, `Terminal.tsx` subscribes to everything, `useSettings` returns the full object. Almost any state change cascades through the entire tree. - -2. **Synchronous I/O on the main thread** — `persistence.ts`, `hooks.ts`, `git/repo.ts`, and `git/worktree.ts` use `readFileSync`/`writeFileSync`/`execSync` in startup and IPC handler paths. - -3. **Unthrottled high-frequency events** — PTY data (no IPC batching), divider drag (fit on every pixel), download progress (no throttle), `before-mouse-event` (all mouse events on all guests), CacheTimer (20 intervals/sec). - -4. **Sequential operations that could be parallel** — Startup server binds, repo iteration in `listAll`, git ref probing, `loadBranchChanges`/`countAheadCommits`, SSH target init. - -5. **Aggressive polling** — Three 3-second intervals per worktree, per-card 1-second cache timers, per-card 5-minute issue polling, 250ms error-page detection. diff --git a/docs/performance-implementation-plan.md b/docs/performance-implementation-plan.md deleted file mode 100644 index 5d3c9a854ec..00000000000 --- a/docs/performance-implementation-plan.md +++ /dev/null @@ -1,179 +0,0 @@ -# Performance Improvement — Implementation Plan - -Branch: `Jinwoo-H/performance-improvement` - ---- - -## Completed (Tier 1) - -### Fix 3 — PTY IPC Data Batching -**Files:** `src/main/ipc/pty.ts` -**Change:** Added 8ms flush-window batching for PTY data. Instead of calling `webContents.send('pty:data', ...)` on every `node-pty` onData event, data is accumulated per-PTY in a `Map` and flushed once per 8ms interval. Reduces IPC round-trips from hundreds/sec to ~120/sec under high throughput. Interactive latency stays below one frame (16ms). - -### Fix 4 — Divider Drag Throttled to rAF -**Files:** `src/renderer/src/lib/pane-manager/pane-divider.ts` -**Change:** Wrapped `refitPanesUnder` calls in `requestAnimationFrame` guard during `onPointerMove`. Previously `fitAddon.fit()` ran on every pointer event (~250Hz), each triggering a full xterm.js reflow (500ms+ with large scrollback). Now capped at 60fps. Cleanup on `onPointerUp` cancels pending rAF and runs one final refit. - -### Fix 5 — Startup Parallelization -**Files:** `src/main/index.ts` -**Change:** `openCodeHookService.start()`, `runtimeRpc.start()`, and `openMainWindow()` now run concurrently via `Promise.all` instead of three sequential `await`s. Window creation no longer blocked by server bind operations. - -### Fix 6 — Async Persistence Writes -**Files:** `src/main/persistence.ts`, `src/main/persistence.test.ts` -**Change:** Debounced `scheduleSave()` now calls `writeToDiskAsync()` using `fs/promises` (writeFile, rename, mkdir) instead of `writeFileSync`/`renameSync`. Synchronous `writeToDiskSync()` retained only for `flush()` at shutdown. Added `waitForPendingWrite()` for test await support. - -### Fix 7 — Parallel Worktree Listing -**Files:** `src/main/ipc/worktrees.ts` -**Change:** `worktrees:listAll` handler now uses `Promise.all(repos.map(...))` instead of sequential `for...of` loop. Total time = slowest repo, not sum of all repos. Each repo's `listRepoWorktrees` spawns `git worktree list` subprocess independently. - -### Fix 8 — BrowserManager Reverse Map -**Files:** `src/main/browser/browser-manager.ts` -**Change:** Added `tabIdByWebContentsId` reverse Map maintained in sync with `webContentsIdByTabId`. Replaced two O(N) `[...entries()].find()` scans with O(1) `.get()` lookups. Updated `registerGuest`, `unregisterGuest`, and `unregisterAll` to keep both maps in sync. - -### Fix 9 — Context Menu Listener Scoping -**Files:** `src/main/browser/browser-guest-ui.ts` -**Change:** `before-mouse-event` listener is now installed only when a context menu is open (on `context-menu` event) and removed on first `mouseDown` (dismiss). Previously fired for every mouse event on every guest surface. - -### Fix 10 — GitHub Cache TTL on Worktree Switch -**Files:** `src/renderer/src/store/slices/github.ts`, `src/renderer/src/store/slices/worktrees.ts`, `src/renderer/src/store/slices/store-cascades.test.ts` -**Change:** Added `refreshGitHubForWorktreeIfStale()` that checks cache age before fetching. `setActiveWorktree` now calls this instead of `refreshGitHubForWorktree` (which always force-refreshes). Eliminates unnecessary GitHub API calls on rapid worktree switching. Force-refresh still available via explicit user action. - -### Fix 20 (Tier 2 bonus) — PTY Resize Fire-and-Forget -**Files:** `src/main/ipc/pty.ts`, `src/preload/index.ts` -**Change:** `pty:resize` changed from `ipcMain.handle`/`ipcRenderer.invoke` (round-trip) to `ipcMain.on`/`ipcRenderer.send` (fire-and-forget). Halves IPC traffic for terminal resize events since the renderer never awaited the response anyway. - ---- - -## Completed (Tier 1 — continued) - -### Fix 1b — Session Persistence Extracted from React -**Files:** `src/renderer/src/App.tsx` -**Change:** Replaced the session-persistence `useEffect` (which had ~15 Zustand subscriptions as deps) with a single `useAppStore.subscribe()` call that runs outside React's render cycle. The subscriber debounces writes to disk via `window.setTimeout(150ms)`. This removed 12 `useAppStore` subscriptions from App's render cycle (`activeRepoId`, `terminalLayoutsByTabId`, `openFiles`, `activeFileIdByWorktree`, `activeTabTypeByWorktree`, `activeTabIdByWorktree`, `browserTabsByWorktree`, `browserPagesByWorkspace`, `activeBrowserTabIdByWorktree`, `unifiedTabsByWorktree`, `groupsByWorktree`, `activeGroupIdByWorktree`) — none of which ever drove JSX. - -### Fix 1a — Consolidated Action Subscriptions -**Files:** `src/renderer/src/App.tsx` -**Change:** Consolidated 19 stable action-ref subscriptions (`toggleSidebar`, `fetchRepos`, `openModal`, `setRightSidebarTab`, etc.) into a single `useShallow` selector returning an `actions` object. Since Zustand actions are referentially stable, the shallow equality check always passes and this subscription never triggers a re-render. All call sites updated to `actions.fetchRepos()`, `actions.toggleSidebar()`, etc. - -### Fix 1c — React.memo Barriers for Children -**Files:** `src/renderer/src/components/sidebar/index.tsx`, `src/renderer/src/components/Terminal.tsx`, `src/renderer/src/components/right-sidebar/index.tsx`, `src/renderer/src/components/status-bar/StatusBar.tsx` -**Change:** Wrapped `Sidebar`, `Terminal`, `RightSidebar`, and `StatusBar` in `React.memo`. These components accept no props from App — they read state from the store directly. The memo barrier prevents App's remaining re-renders (from layout state like `sidebarWidth`, `activeView`) from cascading into the full component tree. - -### Fix 2 — Terminal.tsx Scoped Subscribe -**Files:** `src/renderer/src/components/Terminal.tsx` -**Change:** The `useAppStore.subscribe()` that destroys orphaned browser webviews now short-circuits with a reference equality check (`state.browserTabsByWorktree === prevBrowserTabs`). Previously fired on every store mutation; now only runs the O(tabs) scan when `browserTabsByWorktree` actually changes. - -**Total App.tsx subscription reduction: 53 → 22 (58% fewer)** - ---- - -## Planned — Tier 2 - -### Fix 11 — Async Git Username/Login -**Files:** `src/main/git/repo.ts` -**Change:** Replace `execSync('gh api user ...')` and `gitExecFileSync` chains in `getGhLogin`, `getGitUsername`, and `getDefaultBaseRef` with their async equivalents. `getDefaultBaseRefAsync` already exists — remove the sync variant and migrate all callers. The `Store.hydrateRepo` call in `getRepos()` is synchronous and uses `getGitUsername` — convert to a lazy-populate pattern where the `gitUsername` field is initially empty and filled by an async hydration pass after construction. - -### Fix 12 — Async Hooks File I/O -**Files:** `src/main/hooks.ts` -**Change:** Convert `loadHooks`, `hasHooksFile`, `hasUnrecognizedOrcaYamlKeys`, `readIssueCommand`, `writeIssueCommand`, and `createWorktreeRunnerScript` from `readFileSync`/`writeFileSync`/`mkdirSync`/`gitExecFileSync` to `fs/promises` + `gitExecFileAsync`. Update all callers in `src/main/ipc/worktrees.ts` to await the new async versions. - -### Fix 13 — Narrow `useSettings` Selector -**Files:** `src/renderer/src/store/selectors.ts`, all 10 consumers -**Change:** The current `useSettings = () => useAppStore((s) => s.settings)` returns the entire GlobalSettings object (~30 fields). Any setting change re-renders every consumer. Replace with field-specific selectors or use `useShallow` at each call site to select only the fields used by that component: -- `App.tsx` only uses `settings.theme` → `useAppStore((s) => s.settings?.theme)` -- `MonacoEditor.tsx` uses font/tab/theme → `useShallow` for those 3 fields -- `AddWorktreeDialog.tsx` uses one field → direct selector - -### Fix 14 — Narrow sortEpoch Bumping -**Files:** `src/renderer/src/store/slices/terminals.ts` -**Change:** `updateTabTitle` currently bumps `sortEpoch` on every title string change for background worktrees (line 354). Title strings change frequently during agent runs (shell prompts, command names). Only bump `sortEpoch` when the agent working/idle status boundary is actually crossed. This requires comparing the old and new title against the agent-status detection logic before deciding to increment. - -### Fix 15 — Shared CacheTimer Interval -**Files:** `src/renderer/src/components/sidebar/CacheTimer.tsx` -**Change:** Each `CacheTimer` instance creates its own 1-second `setInterval`. With 20 visible cards this means 20 intervals firing per second, each running a Zustand selector that iterates `Object.keys(s.cacheTimerByKey)`. Replace with a single shared interval at the module level (or in the store slice) that updates a `remainingByWorktreeId` map in one `set()` call. Components subscribe to only their specific worktree's entry. - -### Fix 16 — Consolidate Git Status Polling -**Files:** `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts` -**Change:** Three `setInterval(fn, 3000)` calls run simultaneously: git status, fetchWorktrees, and stale conflict poll. The worktree list poll (every 3s) is aggressive — branch changes inside terminals are low-frequency. Consolidate into a single interval: -- Git status poll: keep at 3s (drives diff gutter, status badge) -- Worktree list poll: increase to 15s (only needed when user runs `git checkout` in terminal) -- Stale conflict poll: keep at 3s but only when stale worktrees exist (already gated) - -### Fix 17 — React.memo on Heavy Components -**Files:** `SourceControl.tsx`, `EditorPanel.tsx`, `FileExplorer.tsx`, `TabBar.tsx` -**Change:** Wrapped all four in `React.memo`. Combined with the Tier 1 memo barriers on Sidebar/Terminal/RightSidebar/StatusBar, a total of 8 heavy components now prevent parent re-render cascades. - -### Fix 18 — Debounce Git Polling on Worktree Switch -**Files:** `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts` -**Change:** Replaced the immediate `void fetchStatus()` and `void fetchWorktrees(activeRepoId)` calls with 150ms `setTimeout` debounces. The interval polling continues as before. Rapid worktree switching now only fires one git status + one git worktree list subprocess instead of N. - -### Fix 19 — Cache FileExplorer dirCache Per Worktree -**Files:** `src/renderer/src/components/right-sidebar/useFileExplorerTree.ts` -**Change:** `dirCache` is local `useState` — reset on every worktree switch. Cache the directory tree per worktree in a `useRef>()` at the hook level. On switch, restore from cache instantly (with a background revalidation fetch). This makes repeated worktree switches O(1) for the file explorer. - -### Fix 21 — Local PTY Flow Control -**Files:** `src/main/providers/local-pty-provider.ts`, `src/renderer/src/components/terminal-pane/pty-dispatcher.ts` -**Change:** Wire up the already-defined `PTY_FLOW_HIGH_WATERMARK` (100KB) and `PTY_FLOW_LOW_WATERMARK` (5KB) constants. Track pending bytes per PTY in the renderer's `EagerPtyBuffer`. When pending exceeds high watermark, send an IPC message to pause the node-pty stream. Resume when acknowledged down to low watermark. The `acknowledgeDataEvent` channel is already plumbed — just needs implementation. - -### Fix 22 — Narrow BrowserPane Selector -**Files:** `src/renderer/src/components/browser-pane/BrowserPane.tsx` -**Change:** Line 312: `useAppStore((s) => s.browserPagesByWorkspace)` subscribes to the entire map. Any tab's navigation re-renders all BrowserPane instances. Narrow to: `useAppStore((s) => s.browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES)`. - -### Fix 23 — Index-Based findPage/findWorkspace -**Files:** `src/renderer/src/store/slices/browser.ts` -**Change:** `findWorkspace` and `findPage` (lines 221-240) use `Object.values().flat().find()` on every navigation event. Accept `worktreeId`/`workspaceId` as a hint parameter and do direct key access: `browserTabsByWorktree[worktreeId]?.find(...)` instead of flattening across all worktrees. - -### Fix 24 — Throttle Download Progress -**Files:** `src/main/browser/browser-manager.ts` -**Change:** `download.item.on('updated', ...)` fires at full Chromium frequency. Add per-download throttle timer — only call `sendDownloadProgress` at most once per 250ms. Clear throttle timer on download done/cancel. - -### Fix 25 — Parallelize detectConflictOperation with git status -**Files:** `src/main/git/status.ts` -**Change:** `getStatus()` now kicks off both `detectConflictOperation()` and `git status` concurrently. The conflict detection promise is started first and awaited before the status result, preserving error semantics while overlapping I/O. - -### Fix 26 — Parallelize getBranchCompare -**Files:** `src/main/git/status.ts` -**Change:** `loadBranchChanges` and `countAheadCommits` now run via `Promise.all` instead of sequentially. These are independent git subprocess calls. - -### Fix 27 — Async addWorktree -**Files:** `src/main/git/worktree.ts`, `src/main/ipc/worktree-remote.ts`, `src/main/runtime/orca-runtime.ts` -**Change:** Converted `addWorktree` from synchronous (`gitExecFileSync`) to async (`gitExecFileAsync`). This was the last major sync git operation on the main thread — 4-5 sequential subprocess calls that blocked the event loop during worktree creation. Updated callers and all 7 tests. - ---- - -## Benchmark Validation Results (2026-04-14) - -Ran 6 targeted benchmarks to validate optimization claims. Full scripts in `benchmarks/`. - -| Fix | What was measured | Result | Verdict | -|-----|-------------------|--------|---------| -| 3 — PTY Batching | IPC calls/sec: unbatched vs 8ms window | 5000→125 calls/sec (98% reduction) | **Validated — high impact** | -| 5/7 — Parallelization | Sequential vs parallel subprocess at N=5 | 119ms→46ms (2.6x, 73ms saved) | **Validated — high impact** | -| 6 — Async I/O | Main-thread blocking: sync vs fire-and-forget | 439µs→21µs per write (21x less blocking) | **Validated — high impact** | -| 1 — Zustand Subs | Selector cost: 53 subs vs 18 subs | 2.61µs→1.59µs per mutation (1.6x) | **Validated — moderate** (real win is cascade prevention via React.memo) | -| 8 — Reverse Map | entries().find() vs Map.get() at N=10 | 409ns→1ns (479x) but 0.04ms/sec total | **Deprioritize** — micro-optimization | -| 23 — flat().find() | Object.values().flat().find() vs direct at 50 tabs | 1958ns→62ns (31x) but 0.02ms/sec total | **Deprioritize** — micro-optimization | - -### Deprioritized based on benchmarks - -The following fixes are technically correct but save <0.1ms/sec at realistic load. Moved to "nice to have": -- Fix 8 (Reverse Map) — already implemented, keep as-is -- Fix 14 (sortEpoch) — per-mutation overhead is negligible -- Fix 15 (CacheTimer shared interval) — 20 intervals/sec is fine for modern JS engines -- Fix 22 (BrowserPane selector) — sub-microsecond per render -- Fix 23 (findPage/findWorkspace) — 0.02ms/sec at 10 nav/sec -- Fix 24 (Download progress throttle) — infrequent event -- Fix 26 (getBranchCompare parallel) — implemented anyway since it was a one-liner - ---- - -## Key Themes - -1. **Zustand subscription granularity** — Fixes 1, 2, 13, 14, 17, 22 all reduce the blast radius of state changes on React re-renders. - -2. **Synchronous I/O on main thread** — Fixes 6, 11, 12, 27 convert blocking filesystem and git operations to async. - -3. **Unthrottled high-frequency events** — Fixes 3, 4, 9, 15, 20, 24 cap event processing to reasonable rates. - -4. **Sequential → parallel** — Fixes 5, 7, 25, 26 run independent async operations concurrently. - -5. **Aggressive polling** — Fixes 16, 18 consolidate and debounce polling intervals. diff --git a/docs/preload-typecheck-hole.md b/docs/preload-typecheck-hole.md deleted file mode 100644 index dad51a267a5..00000000000 --- a/docs/preload-typecheck-hole.md +++ /dev/null @@ -1,101 +0,0 @@ -# Preload typecheck hole: why project-owned types live in `.ts` - -## The rule - -Project-owned type declarations under `src/preload/` and `src/shared/` -**must live in `.ts` files, not `.d.ts`**. The CI step -"Guard against project-owned .d.ts in preload/shared" in -`.github/workflows/pr.yml` enforces this. - -## Why - -Orca inherits `skipLibCheck: true` from `@electron-toolkit/tsconfig`. -That setting is the ecosystem default — it exists so a broken `.d.ts` -in some random `node_modules` package can't block your build. TypeScript -has no way to scope it to `node_modules`, so **`skipLibCheck` -applies to our own `.d.ts` files too**. - -In a project-owned `.d.ts`, any type reference that fails to resolve -silently becomes `any` at its call sites instead of erroring. Downstream -assignments against that `any` are also silently accepted. The error -never surfaces during `pnpm typecheck`. - -For example, with `skipLibCheck: true`: - -```ts -// in src/preload/index.d.ts — Worktree is never imported -type WorktreesApi = { - list: () => Promise // silently becomes Promise -} -``` - -and at the call site: - -```ts -// in any renderer file -window.api.worktrees.list().then((arr) => { - setWorktreeName(arr) // setWorktreeName expects string; accepted anyway because arr is any[] -}) -``` - -No compile error. Crashes at runtime. - -The standard TS convention that sidesteps this: put project-owned types in -`.ts` (which are always checked), reserve `.d.ts` for ambient shims -(`env.d.ts`, `vite/client.d.ts`, etc.). The CI guard encodes that -convention mechanically. - -## Incident that forced the fix - -PR #1186 changed the `repos:getBaseRefDefault` IPC return shape from -`Promise` to `Promise` (an envelope -object). Two of three renderer callers were updated; the third -(`StartFromField.tsx`) wasn't. That caller passed the envelope object -into a `setState` setter, which rendered as JSX and threw -React error #31 (`Objects are not valid as a React child`). - -**The call site should have been a compile error.** It wasn't, because -`src/preload/index.d.ts` (now deleted) was a 246-line project-owned -`.d.ts` that referenced ~20 type names it never imported (`Worktree`, -`PRInfo`, `GlobalSettings`, `BaseRefDefaultResult`, and more). Under -`skipLibCheck`, each unresolved name became `any`, which widened the -`.then((ref) => …)` callback parameter to `any` at the consuming call -site. `setDefaultBaseRef(ref: any)` compiled cleanly. - -The crash is fixed by #1189. The typecheck hole is fixed by this PR -(#1197), which collapses the two preload type files (`index.d.ts` + -`api-types.d.ts`) into a single type-checked `api-types.ts`. Full design -discussion, alternatives considered, and rollout notes live in PR #1197. - -## Non-obvious subtleties worth remembering - -- **It's not the hand-authored types that failed — it was the missing - imports.** The types in the old `index.d.ts` were individually fine; - the file only went wrong because names like `Worktree` and `PRInfo` - weren't imported and `skipLibCheck` swallowed the error. A future - contributor copy-pasting types out of a `.d.ts` into `.ts` may be - surprised by a wall of "Cannot find name 'X'" errors — that's the - flag catching its target, not a real regression. -- **`.d.ts` is still legitimate for ambient shims.** `env.d.ts`, - `mermaid.d.ts`, `hosted-git-info.d.ts` all live *outside* the CI - guard's scan roots (`src/preload/` and `src/shared/`) and stay as - `.d.ts`. If a future file under those roots genuinely needs to be - `.d.ts` (e.g., an ambient module shim for a third-party package that - can't live in `.ts`), add it to an allowlist in `pr.yml` at that - time — don't relax the guard wholesale. -- **Intersection types on `window.api` are what actually widened the - `.then` callback to `any`.** The old layout used - `type Api = PreloadApi & { repos: ReposApi, worktrees: WorktreesApi, … }`. - TypeScript's intersection-of-function-types resolution widens callback - parameters to `any` when one side of the intersection has unresolved - names, *even though static-inspection views - (`ReturnType`) still report the correct type*. So - `ReturnType` printed - `Promise` during debugging while the live - `.then((ref) => …)` callback treated `ref` as `any`. Don't re-introduce - intersection typing on the preload surface for any reason. -- **Don't try to fix this by flipping `skipLibCheck: false` globally.** - It would force every transitive `@types/*` package to type-check - cleanly, which is why the ecosystem-wide default is `true`. The - structural fix (project-owned types in `.ts`) removes our last - reason to care about the flag for our code. diff --git a/docs/quickly-jump-to-worktree.md b/docs/quickly-jump-to-worktree.md deleted file mode 100644 index 43e48a91b02..00000000000 --- a/docs/quickly-jump-to-worktree.md +++ /dev/null @@ -1,242 +0,0 @@ -# Design Document: Quick Jump to Worktree (Issue #426) - -## 1. Overview - -As Orca scales to support multiple parallel agents and tasks, users frequently need to switch between dozens of active worktrees. Navigating via the sidebar becomes inefficient at scale. - -This document describes the shipped "Quick Jump" palette in Orca: a globally accessible Command Palette-style dialog that lets users jump across active worktrees, open browser tabs that live inside worktrees, and create a new worktree from typed input. Search covers worktree metadata (name, branch, repo, comment, PR metadata, issue metadata) and browser metadata (page title, URL, worktree, repo). - -## 2. User Experience (UX) - -### 2.1 The Shortcut: `Cmd+J` (macOS) / `Ctrl+Shift+J` (Windows/Linux) - -To establish this palette as the central "Switch Worktree" action in Orca, `**Cmd+J**` (macOS) and `**Ctrl+Shift+J**` (Windows/Linux) are the chosen shortcuts. - -**Why `Cmd+J` / `Ctrl+Shift+J`?** - -- **Matches the action honestly:** This palette switches between existing worktrees. "Jump" is a better semantic fit than "Open" because the user is navigating, not creating a new file-open flow. -- **Avoids `Ctrl+J` (Line Feed) conflict:** On Windows and Linux, `Ctrl+J` translates to a Line Feed (`\n`) in bash, zsh, and almost all readline-based CLI applications. For many terminal power users, `Ctrl+J` and `Ctrl+M` (Carriage Return) are used interchangeably with the physical `Enter` key to execute commands. In Vim, it is used for navigation or inserting newlines, and in Emacs it maps to `newline-and-indent`. Intercepting `Ctrl+J` globally would severely disrupt core terminal workflows. Thus, `Ctrl+Shift+J` is used on these platforms. (On macOS, `Cmd` is an OS-level modifier, so `Cmd+J` safely avoids this issue). -- **Avoids `Cmd+K` conflict:** In terminal-heavy apps, `Cmd+K` is universally expected to "Clear Terminal". Overriding it breaks developer muscle memory. -- **Avoids `Cmd+P` conflict:** `Cmd+P` is already in use for Quick Open File (`QuickOpen.tsx`). -- **Avoids `Ctrl+E` (readline):** `Ctrl+E` is "end of line" in bash/zsh readline. Stealing it in a terminal-heavy app would break shell navigation muscle memory — the same class of conflict that rules out `Cmd+K`. -- **Discoverability:** The shortcut should be registered in the Electron Application Menu (e.g., `View -> Open Worktree Palette`) so users can discover it visually. - -### 2.2 The Interface - -When the shortcut is pressed, a modal dialog appears at the center top of the screen (similar to VS Code's palette or Spotlight). - -- **Input:** A text input focused automatically. -- **List:** A scrollable list constrained to `max-h-[min(460px,62vh)]` to prevent the palette from overflowing the viewport when many results are present. -- **Default state (empty query):** When the palette opens with no query, the full list of non-archived worktrees is shown first, ordered by Orca's smart sort. If browser tabs also exist, they appear as a secondary section preview below the worktree list. The palette intentionally ignores the sidebar's `showActiveOnly` and `filterRepoIds` filters — it is a global jump tool, not a filtered view. -- **Sorting (Smart Semantics):** The palette uses Orca's smart worktree ordering via `sortWorktreesSmart(...)`, not plain recency. In practice this prioritizes active agent work, permission-needed state, unread state, live terminals, PR signal, linked issue, and recent activity, with a cold-start fallback to persisted `sortOrder` until any PTY is live. -- **Visual Hierarchy & Highlights:** Because search covers multiple fields simultaneously, the list items must visually clarify *why* a result matched. If the match is inside a comment, display a truncated snippet of that comment centered around the matched range, with the matching text highlighted. -- **Multi-repo disambiguation:** Each list item always displays the repository name (e.g., `stablyai/orca`) alongside the worktree name. This is required because the palette spans all repos — without it, two worktrees named "main" from different repos would be indistinguishable. -- **Cross-surface scope:** The palette is not limited to worktrees. It also surfaces browser tabs, and when the user types a string that matches no worktree results it offers a "Create worktree" action using the current query. -- **Empty State:** Two cases: (1) If the user has no active worktrees and no browser tabs, display "No active worktrees or browser tabs". (2) If items exist but none match the search query, display "No results match your search." Both use ``. -- **Search fields:** The search input will match against: - - Worktree `displayName` - - Worktree `branch`, normalized via `branchName()` to strip the `refs/heads/` prefix (e.g., `refs/heads/feature/auth-fix` → `feature/auth-fix`) - - Repository name (e.g., `stablyai/orca`) - - Full `comment` text attached to the worktree - - Linked PR number/title. Two paths: (a) auto-detected PR via `prCache` (cache key: `${repo.path}::${branch}`), which has both number and title; (b) manual `linkedPR` fallback, which has number only (no title to search against). If `prCache` has a hit, prefer it; otherwise fall back to `linkedPR` number matching. - - Linked issue number/title. The issue number comes from `w.linkedIssue`; the title comes from `issueCache` (cache key: `${repo.path}::${w.linkedIssue}`). Number matching works even without a cache hit; title matching requires the cache entry to be populated. - - **Cache freshness caveat:** PR and issue data is populated by `refreshGitHubForWorktree`, which runs on worktree activation, and by `refreshAllGitHub`, which runs on window re-focus (`visibilitychange`). On startup, `initGitHubCache` loads previously persisted PR/issue data from disk, so worktrees fetched in prior sessions start with warm caches. Worktrees that have never been activated, were not covered by a `refreshAllGitHub` pass, and have no persisted cache entry will have empty caches — PR/issue title search will silently miss them. This is acceptable: the gap is limited to brand-new worktrees between creation and the next activation or window re-focus cycle. Number-based matching (e.g., `#304`) always works because it checks `w.linkedPR` / `w.linkedIssue` directly, without the cache. - - `**#`-prefix handling:** A leading `#` in the query is stripped before matching PR/issue numbers (e.g., `#304` matches number `304`), with a guard against bare `#` which would produce an empty string and match everything. This mirrors the existing `matchesSearch()` behavior. -- **Browser search fields:** Browser page title, URL/secondary text, worktree name, and repo name. -- **Navigation:** `Up` / `Down` arrows to navigate the list, `Enter` to select. `Escape` closes the modal. - -## 3. Technical Architecture - -### 3.1 UI Components - -Orca uses `shadcn/ui` and ships the **Command** component, which wraps the `cmdk` library. - -**Dependency:** `cmdk` is a direct dependency in `package.json`. - -```bash -pnpm dlx shadcn@latest add command -``` - -Note: `CommandDialog` uses Radix Dialog internally. Orca keeps this inside the shared `components/ui/command.tsx` wrapper so both palettes use the same dialog primitives and styling hooks. - -**z-index:** The `CommandDialog` must use `z-50` or higher to reliably overlay the terminal and sidebar, consistent with `QuickOpen.tsx` which uses `z-50` on its fixed overlay container. - -- `**WorktreeJumpPalette.tsx`:** A new component mounted at the root of the app (inside `App.tsx`, alongside the existing ``) to ensure it can be summoned from anywhere. -- `**CommandDialog`:** The shadcn component used to render the modal. - -### 3.2 Keyboard Shortcut - -The shipped shortcut uses a **hybrid main-process + renderer architecture**: - -1. The main window listens in `before-input-event` and resolves the chord through the shared window shortcut policy. -2. On `toggleWorktreePalette`, the main process sends `ui:toggleWorktreePalette` to the renderer. -3. The renderer toggles `activeModal === 'worktree-palette'` in `useIpcEvents.ts`. - -This extra main-process hop is required because browser guests and other embedded Chromium surfaces can keep keyboard focus inside a guest `webContents`, bypassing the renderer's `window`-level `keydown` listener. A renderer-only implementation would fail from browser-tab focus. - -**Toggle semantics:** If the palette is already open, the shortcut closes it; otherwise it opens it. There is no `activeWorktreeId` or `activeView` guard, so the palette is available from settings, from landing states with no active worktree, and from browser focus. - -**Overlay mutual exclusion:** The current app models both Quick Open and the worktree palette inside the existing `activeModal` union in `ui.ts` (`'quick-open'` and `'worktree-palette'`). This keeps the two command palettes mutually exclusive without needing separate booleans. - -**Menu registration:** Register a `View -> Open Worktree Palette` entry in `register-app-menu.ts` for discoverability, consistent with Section 2.1. The entry must use a **display-only shortcut hint** — do **not** set `accelerator: 'CmdOrCtrl+J'`. In Electron, menu accelerators intercept key events at the main-process level *before* the renderer's `keydown` handler fires (this is how `CmdOrCtrl+,` for Settings works — its `click` handler runs in the main process via `onOpenSettings`). If `CmdOrCtrl+J` were registered as a real accelerator, the renderer `keydown` handler would never see the event, and the overlay mutual-exclusion logic (which runs in the renderer) would be bypassed. Instead, show the shortcut text in the menu label (e.g., `label: 'Open Worktree Palette\tCmdOrCtrl+J'`) without binding `accelerator`, matching the pattern used by `Cmd+P` (QuickOpen), which has no menu entry at all and relies solely on the renderer handler. - -### 3.3 State Management - -- **Visibility state:** The palette is represented by `activeModal === 'worktree-palette'` in the UI slice. Quick Open similarly uses `activeModal === 'quick-open'`. -- **Palette session state:** `query` and `selectedIndex` are ephemeral to the palette component and should live in React component state (not Zustand). They reset on every open. -- **Render optimization:** When the modal is closed, `CommandDialog` unmounts its content, which is sufficient. -- **Ordering:** Worktree results are fed through `sortWorktreesSmart(...)`, and browser results are ordered relative to that same worktree ordering so both sections feel consistent. - -### 3.4 Data Layer & Search - -The palette needs access to all worktrees known to Orca. - -- **Data source:** Read from the existing `worktreesByRepo` in Zustand (already populated via `fetchAllWorktrees` on startup and kept in sync via IPC push events). No new IPC channel is needed. Filter out archived worktrees (`!w.isArchived`) before searching or displaying. Do **not** apply the sidebar's `showActiveOnly` or `filterRepoIds` filters — the palette is a global jump tool that surfaces all non-archived worktrees regardless of the sidebar's filter state. Because the palette reads directly from `worktreesByRepo`, it reactively updates if a worktree is created or deleted via IPC push while the palette is open — no special stale-list handling is needed. - -#### Search implementation - -The sidebar already has a `matchesSearch()` function in `worktree-list-groups.ts` that does **substring matching** (`includes(q)`) against displayName, branch, repo, comment, PR, and issue fields. The palette search builds on this foundation but extends it. Note: `branchName()` (used to strip `refs/heads/` prefixes) is currently exported from `worktree-list-groups.ts` — a sidebar-specific module that imports Lucide icons (`CircleCheckBig`, `CircleDot`, etc.) at the top level. Importing `branchName` from it would pull the entire module (including unused icon components) into the palette's bundle. `smart-sort.ts` has its own duplicate: `branchDisplayName()` doing the identical `branch.replace(/^refs\/heads\//, '')`. Extract `branchName()` to a shared utility (`lib/git-utils.ts`) in Phase 1, and update `worktree-list-groups.ts` and `smart-sort.ts` to import from there. This is a 3-line function — the extraction is trivial and avoids the bundle bloat. - -1. **Matching strategy: substring, not fuzzy.** Use case-insensitive substring matching for worktrees and browser entries. True fuzzy matching (ordered-character, like `QuickOpen.tsx`'s `fuzzyMatch`) is not used here. -2. **Structured match metadata:** Unlike `matchesSearch()` (which returns `boolean`), the palette search helper returns a result object: - -```ts -type MatchRange = { start: number; end: number } - -type PaletteMatchBase = { worktreeId: string } - -/** Empty query — all non-archived worktrees shown, no match metadata. */ -type PaletteMatchAll = PaletteMatchBase & { - matchedField: null - matchRange: null -} - -/** Comment match — includes a truncated snippet centered on the matched range. */ -type PaletteMatchComment = PaletteMatchBase & { - matchedField: 'comment' - matchRange: MatchRange - snippet: string -} - -/** Non-comment field match — range within the matched field's display value. */ -type PaletteMatchField = PaletteMatchBase & { - matchedField: 'displayName' | 'branch' | 'repo' | 'pr' | 'issue' - matchRange: MatchRange -} - -type PaletteMatch = PaletteMatchAll | PaletteMatchComment | PaletteMatchField -``` - -3. **Field priority order:** When multiple fields match, report the first match by priority: `displayName` > `branch` > `repo` > `comment` > `pr` > `issue`. This determines which badge/highlight is shown. -4. **Comment snippet extraction:** Search against the full `comment` text. Only the *rendered snippet* is truncated — extract ~80 characters of surrounding context centered on the matched range. Clamping: `snippetStart = Math.max(0, matchStart - 40)`, `snippetEnd = Math.min(comment.length, matchEnd + 40)`. After clamping, snap to word boundaries: scan `snippetStart` backward (up to 10 chars) to the nearest whitespace or string start; scan `snippetEnd` forward (up to 10 chars) to the nearest whitespace or string end. This avoids cutting words mid-character (e.g., `…e implementation of th…` → `…the implementation of the…`). Prepend `…` if `snippetStart > 0`; append `…` if `snippetEnd < comment.length`. -5. `**cmdk` wiring:** Render with `shouldFilter={false}` so the palette controls filtering. Pass only the filtered result set to ``: - -```tsx - handleSelectWorktree(worktree.id)} -> - {/* Render worktree row with match badge + highlighted range */} - -``` - -6. **Performance:** Keep `value` compact and do not stuff full comments into `keywords`. The current implementation debounces the query by 150ms before recomputing result sets. That keeps mixed worktree + browser searching cheap without materially hurting responsiveness at Orca's current scale. - -### 3.5 Action (Worktree Activation) - -#### Existing callsite analysis - -The codebase has several worktree activation paths with inconsistent step coverage: - - -| Step | `WorktreeCard` click | `Cmd+1–9` | `AddRepoDialog` | `AddWorktreeDialog` | -| ------------------------------------ | -------------------- | --------- | --------------- | ------------------- | -| Set `activeRepoId` | No | No | Yes | Yes | -| Set `activeView` | No | No | Yes | Yes | -| `setActiveWorktree()` | Yes | Yes | Yes | Yes | -| `ensureWorktreeHasInitialTerminal()` | No | No | Yes | Yes | -| `revealWorktreeInSidebar()` | No | Yes | Yes | Yes | - - -Sidebar card clicks and `Cmd+1–9` work without setting `activeRepoId` because `activeRepoId` is only consumed by the "Create Worktree" dialog (to pre-select a repo) and session persistence — it does not gate rendering or data fetching for the switched-to worktree. Similarly, `ensureWorktreeHasInitialTerminal` is only needed for newly created worktrees that have never been opened; existing worktrees already have terminal tabs. - -#### Palette activation sequence - -The palette should match what `Cmd+1–9` does today (the closest analog: jumping to a visible worktree from any context), plus a few extras justified by the palette's cross-repo scope: - -1. **Set `activeRepoId`:** If the target worktree's `repoId` differs from the current `activeRepoId`, call `setActiveRepo(repoId)`. This keeps session persistence and the "Create Worktree" repo pre-selection accurate. Sidebar clicks skip this because they operate within a single repo group; the palette does not have that constraint. -2. **Switch `activeView`:** If `activeView` is `'settings'`, set it to `'terminal'` so the main content area renders the worktree surface. `Cmd+1–9` does not handle this because it refuses to fire at all from the settings view (gated on `activeView !== 'settings'` in the `onKeyDown` handler); the palette intentionally has no such guard so users can jump to a worktree directly from settings. -3. **Call `setActiveWorktree(worktreeId)`:** This runs Orca's existing activation sequence: sets `activeWorktreeId`, restores per-worktree editor state (`activeFileId`, `activeTabType`, `activeBrowserTabId`), restores the last-active terminal tab, clears unread state, bumps dead PTY generations, and triggers `refreshGitHubForWorktree` to ensure PR/issue/checks data is current for the newly active worktree. -4. **Ensure a focusable surface:** If the worktree has no renderable tabs, call `ensureWorktreeHasInitialTerminal` (`worktree-activation.ts`). The helper now decides this via the reconciled tab model, not by checking whether the legacy terminal-tab array is empty. -5. **Reveal in sidebar:** Call `revealWorktreeInSidebar(worktreeId)` to ensure the selected worktree is visible (handles collapsed groups and scroll position). -6. **Close the palette.** - -#### Shared helper - -The five activation steps above overlap heavily with `AddRepoDialog.handleOpenWorktree` and `AddWorktreeDialog`'s post-create flow. With three callsites now sharing the same core sequence, extract a shared `activateAndRevealWorktree(worktreeId: string, opts?: { setup?: WorktreeSetupLaunch })` helper in `worktree-activation.ts` that covers the common steps: set `activeRepoId` (cross-repo), switch `activeView` (from settings), `setActiveWorktree`, `ensureWorktreeHasInitialTerminal`, clear sidebar filters that would hide the target, and `revealWorktreeInSidebar`. - -**Sidebar filter clearing:** The helper must clear any sidebar filter state that would prevent the target card from being rendered, because `revealWorktreeInSidebar` relies on the worktree card being *rendered* in the sidebar (the `pendingRevealWorktreeId` effect in `WorktreeList` finds the target in the rendered `rows` array via `findIndex`). If sidebar filters exclude the target, the card is never rendered and the reveal silently no-ops — the user selects a worktree and nothing visually happens. `AddWorktreeDialog` already handles this inline (clears both `searchQuery` and `filterRepoIds` before activation); the shared helper absorbs that responsibility. Specifically: - -- Clear `filterRepoIds` if it is non-empty and does not include the target worktree's repo. -- Clear `searchQuery` unconditionally if it is non-empty. Even if the target repo is visible, an active text search might exclude the specific worktree being jumped to. - -Callsite-specific extras that remain inline after calling the shared helper: - -- `**AddWorktreeDialog`:** `setSidebarOpen(true)`, open right sidebar if `rightSidebarOpenByDefault`. -- `**AddRepoDialog`:** `closeModal()` (the palette closes itself separately). -- **Palette:** close the palette, focus management (Section 3.5 Focus management). - -The helper derives `repoId` internally via `findWorktreeById(worktreesByRepo, worktreeId)` (`worktree-helpers.ts:45`) — the caller only passes `worktreeId`. If the worktree is not found (e.g., deleted between palette open and select), the helper returns early without side effects. - -#### Focus management - -- **On worktree select:** After closing the palette, use a double `requestAnimationFrame` (nested rAF) to focus the active terminal/editor surface for the target worktree. `onCloseAutoFocus` calls `preventDefault()` so Radix does not steal focus. -- **On browser-tab select:** Restore focus to the selected browser page, preferring the address bar for blank/new pages and the webview for loaded pages. -- **On escape/cancel:** Restore focus to the previously active browser page when the palette was opened from browser context; otherwise fall back to the terminal/editor surface for the previously active worktree. If no worktree was active, focus falls to the document body. - -### 3.6 Accessibility - -The `cmdk` library provides built-in ARIA support: - -- `role="combobox"` on the input -- `role="listbox"` / `role="option"` on the list and items -- `aria-activedescendant` for keyboard navigation -- `aria-expanded` on the dialog - -**Additional requirements:** - -- Announce filtered result count changes to screen readers via an `aria-live="polite"` region (e.g., "3 worktrees found"). -- Match-field badges (e.g., `Branch`, `Comment`) should include `aria-label` text so screen readers convey why the result matched. - -## 4. Implementation Status - -The core design is implemented: - -- `cmdk` is a direct dependency and Orca ships a shared `CommandDialog`. -- `WorktreeJumpPalette.tsx` is mounted at the app root. -- The palette opens through main-process shortcut forwarding plus renderer IPC toggle handling. -- Worktree activation is routed through `activateAndRevealWorktree(...)`. -- Search supports comment snippets, PR/issue metadata, browser pages, and a create-worktree action. -- Menu discoverability is implemented with a display-only `View -> Open Worktree Palette` hint and no accelerator binding. - -## 5. Remaining Gaps / Future Work - -- Add broader integration coverage for the mixed worktree/browser palette behavior; current tests focus on search helper behavior and shortcut/menu plumbing. -- Evaluate whether the 150ms debounce should become adaptive if the palette eventually indexes substantially more browser pages. -- Consider unifying the worktree and browser result models further if future result types are added. - -**Future work (out of scope)** - -- Evaluate migrating `QuickOpen.tsx` (currently a custom overlay with manual keyboard handling) to `cmdk`/`CommandDialog` for visual and behavioral consistency with the palette. This is a separate project — `QuickOpen` has its own fuzzy matching, file-loading, and keyboard handling that would need reworking. -- Add richer end-to-end coverage for palette interactions launched from browser focus, including focus restoration after browser-tab selection and dismissal. - -## 6. Alternatives Considered - -- `**Cmd+O` (Open):** Standard app semantic, but less honest for this feature because the palette switches between existing worktrees rather than opening a new file or workspace. Rejected in favor of `Cmd+J`, which better matches the action users are taking. -- `**Ctrl+E` (Explore):** Initially considered for Windows/Linux. Rejected because `Ctrl+E` is "end of line" in bash/zsh readline — stealing it in a terminal-heavy app breaks shell navigation muscle memory. -- `**Ctrl+Alt+O`:** Initially considered for Windows/Linux but rejected to avoid `AltGr` collisions on international keyboards (e.g., Polish, German layouts). -- `**Cmd+1...9` (Direct jumping):** Doesn't scale past 9 worktrees and requires the user to memorize sidebar positions. Already implemented as a complementary feature. -- `**Cmd+K`:** Rejected due to conflict with "Clear Terminal". -- `**Cmd+P`:** Rejected because it is already used for file searching (`QuickOpen.tsx`). -- **Renderer-only shortcut handling:** Initially attractive because it mirrors simpler shortcuts, but rejected for the shipped palette. Browser guests can keep keyboard focus inside a separate `webContents`, so a renderer-only `window` listener would miss `Cmd+J` / `Ctrl+Shift+J` from browser-tab focus. diff --git a/docs/refresh-on-checks-tab.md b/docs/refresh-on-checks-tab.md new file mode 100644 index 00000000000..392b5de0609 --- /dev/null +++ b/docs/refresh-on-checks-tab.md @@ -0,0 +1,165 @@ +# Auto-refresh on entering the Checks tab + +## Problem + +The Checks panel fetches on visibility, but those fetches are cache-respecting: + +- `CACHE_TTL = 300_000` for PRs and comments. +- `CHECKS_CACHE_TTL = 60_000` for checks. +- `ChecksPanel.tsx` fetches PRs on repo/branch changes without `force`. +- Checks polling and the comments effect also call the store without `force`. + +That means entering the Checks tab can render stale PR metadata, a cached `null` +"no PR" result, stale comments, or stale checks. The checks path is partially +protected by a shorter renderer TTL, but the main process also uses `gh api +--cache 60s` for the REST checks endpoint unless `noCache` is set. Manual refresh +already bypasses these renderer and `gh api` caches for the requests it starts. + +## Goal + +When the user enters the Checks tab, run one freshness check for the active +worktree and force-refresh only when the relevant cache timestamps are older +than a small grace window. "Entering" means: + +- opening the right sidebar while the Checks tab is selected; +- switching from another right-sidebar tab to Checks; +- switching active worktree/repo/branch while Checks is already visible. + +The refresh must cover PR discovery too, including cached `null`, so a PR opened +outside Orca can appear without waiting up to 5 minutes. + +## Non-goals + +- Changing the existing polling cadence while the panel stays visible. +- Refreshing on window focus, network reconnect, or arbitrary background events. +- Changing the manual refresh button. +- Prefetching checks for non-active worktrees. +- Adding cross-renderer or cross-window request coordination. Orca currently has + one live main window; renderer in-flight maps are not a multi-window primitive. + +## Design + +1. **Entry trigger.** Add an effect in `ChecksPanel.tsx` keyed by: + + ```ts + const entryKey = isPanelVisible && repo && !isFolder && branch + ? `${activeWorktreeId ?? ''}::${repo.path}::${branch}` + : '' + ``` + + Track the last processed visible `entryKey` in a ref. When `entryKey` is empty, + reset the ref to `''`. When it becomes non-empty and differs from the ref, this + is an entry. This is required; a `prevKey !== currentKey` check that does not + reset on hide would miss closing and reopening the same PR. + +2. **Grace window.** Define `ENTRY_REFRESH_GRACE_MS = 15_000` in + `ChecksPanel.tsx`. Select only timestamps, not whole cache records: + + ```ts + const prFetchedAt = useAppStore( + (s) => (prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined) + ) + const checksFetchedAt = useAppStore( + (s) => prNumber + ? s.checksCache[`${repo?.path ?? ''}::pr-checks::${prNumber}`]?.fetchedAt + : undefined + ) + const commentsFetchedAt = useAppStore( + (s) => prNumber + ? s.commentsCache[`${repo?.path ?? ''}::pr-comments::${prNumber}`]?.fetchedAt + : undefined + ) + ``` + + Missing PR cache is stale. A cached PR value of `null` is still a PR cache + entry and should be refreshed on entry once outside the grace window. When a + PR number is known, missing checks/comments timestamps are stale. When no PR is + known, checks/comments are not relevant yet. + + Run `handleRefresh()` if the oldest relevant timestamp is missing or older + than `Date.now() - ENTRY_REFRESH_GRACE_MS`; otherwise skip. + +3. **Reuse `handleRefresh`, but pass the refreshed head SHA through.** The + manual refresh flow is the right shape: force `fetchPRForBranch`, then if a + PR is returned, force checks and comments for the returned PR. Extend + `fetchChecks()` to accept a `headShaOverride` (or call `fetchPRChecks` + directly from `handleRefresh`) so the checks request uses + `refreshedPR.headSha`, not the stale `pr?.headSha` captured before the PR + refresh completed. This handles PR number changes, cached `null`, and + external force-pushes correctly. + +4. **Reset polling attention state before refresh.** When the entry refresh runs, + set `pollIntervalRef.current = 30_000` and `prevChecksRef.current = ''` before + calling `handleRefresh()`. `fetchChecks()` will then write the new signature + from the forced result. + +5. **Do not overstate in-flight behavior.** Current store behavior is: + + - `fetchPRForBranch({ force: true })` bypasses a non-forced in-flight PR + request and uses a generation guard so the older result cannot overwrite the + newer cache entry. + - `fetchPRChecks({ force: true })` and `fetchPRComments({ force: true })` do + **not** bypass any in-flight request for the same key. If a non-forced poll + is already in flight, entry refresh will join it and may not pass + `noCache: true` to the main process. + + Accept that tradeoff for this feature. It avoids duplicate `gh` calls during a + visible polling race. If strict "entry always bypasses gh cache" semantics are + required later, change checks/comments in-flight maps to track `{ promise, + force, generation }` like PRs. + +## API cost and feasibility + +This is not free. A cold entry refresh can start: + +- one PR lookup (`gh:prForBranch`); +- one checks request (`gh:prChecks`); +- one comments request (`gh:prComments`). + +`gh:prChecks` usually calls `gh api repos/{owner}/{repo}/commits/{sha}/check-runs` +and uses `--cache 60s` unless forced; if that fails it falls back to `gh pr +checks`, which does not use the `--cache` flag. + +`gh:prComments` is heavier than "one GitHub API call": it runs issue comments +REST, review threads GraphQL, and reviews REST in parallel. `noCache` only +removes `--cache 60s` from the REST `gh api` calls; the GraphQL call is always +made. + +The 15 s grace window is therefore required, not cosmetic. + +## Edge cases + +- **Cached no-PR result.** Do not skip just because `prNumber` is null. Refresh + the PR cache entry on tab entry when its timestamp is outside the grace window. +- **First Checks entry after app start.** Only `prCache` and `issueCache` are + persisted. `checksCache` and `commentsCache` start empty, so a known PR should + force checks/comments on entry. +- **Worktree switch while Checks is visible.** Include `activeWorktreeId` in the + entry key so same repo/branch switches still count as a new entry. The existing + render-time local reset handles stale title/loading state. +- **Rapid tab toggles.** Hiding the panel resets the processed entry key; showing + it again re-evaluates timestamps. The grace window suppresses duplicate calls. +- **Concurrent polling tick.** Checks/comments may reuse the in-flight poll + request instead of forcing a new `noCache` request. This is intentional for now. +- **Conflicting PR refresh.** The existing `mergeable === 'CONFLICTING'` effect + can still force a PR refresh. Entry refresh may also run, but PR generation + guards prevent older PR responses from overwriting newer cache entries. +- **PR head changed externally.** `handleRefresh()` must use the refreshed PR + returned by `fetchPRForBranch` before fetching checks, so checks are requested + with the current `headSha`. +- **Component unmount mid-fetch.** The comments auto-fetch guards stale + responses; the checks fetch path and `handleRefresh()` do not cancel local + setters. This feature should keep that behavior unless tests expose a warning + or stale-state regression. +- **SSH / remote repo.** No path manipulation or platform-specific shortcut code + is needed. The same IPC-backed `gh` path is used. + +## Rollout + +1. Add timestamp selectors, `ENTRY_REFRESH_GRACE_MS`, and the entry effect in + `ChecksPanel.tsx`. +2. Keep `handleRefresh()` as the single refresh implementation. +3. Add `ChecksPanel.test.tsx` if absent. Cover stale PR/null cache refreshes, + fresh-within-grace skips, known PR with missing checks/comments refreshes, + hidden panel no-op, and hide/show same PR re-evaluation. +4. Run `pnpm typecheck && pnpm lint`. diff --git a/docs/remote-folder-picker-redesign.md b/docs/remote-folder-picker-redesign.md deleted file mode 100644 index 79036d1b607..00000000000 --- a/docs/remote-folder-picker-redesign.md +++ /dev/null @@ -1,119 +0,0 @@ -# Remote SSH Folder Picker — Redesign Proposal - -## Context - -The "Browse remote filesystem" dialog lets users pick a directory on a connected SSH target to open as a remote project. It's reached from: sidebar → Add project → Open remote project → folder-picker icon next to the Remote path input. - -**Source:** `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx` -**Invoked from:** `src/renderer/src/components/sidebar/AddRepoSteps.tsx` (`RemoteStep`, around line 167) - -## User complaints - -1. **Hard to find the desired folder** — list is flat and unfiltered; on a home directory with 30+ dotfiles/dirs, you scroll. -2. **Nested folders not discoverable as navigable** — current model is single-click = highlight, double-click = navigate. Users don't discover the double-click. -3. **Unclear consequence of pressing "Select"** — button is generic; the dialog doesn't say what happens to the chosen folder next. - -## Current behavior (for reference) - -- Single-click a row → sets `selectedName`, highlights the row. -- Double-click a folder row → navigates into it. -- "Select" button → if a row is highlighted, returns `/`; otherwise returns `resolvedPath` (the current directory). -- Breadcrumb bar at top with ↑, 🏠, and clickable path segments. -- Footer shows the path that will be returned: either current dir or `current/highlighted`. - -## Reference: how others do it - -- **VS Code** (`simpleFileDialog.ts`): one unified input at the top doubles as (a) current path, (b) filter as you type, (c) editable path entry. Enter key navigates into folders; OK button label is caller-supplied ("Open Folder", etc.). Auto-complete suggests folders inline. -- **Superset, Warp**: no comparable picker. - -## Options considered - -### Option A — VS Code-style: selection = current directory -Drop the "highlight a row" model entirely. Navigate into the folder you want (single-click enters it), then "Select" always returns the current directory. - -**Rejected because:** breaks Finder/Explorer muscle memory (double-click to open is a universal convention), and adds a click for the common case of picking a visible child folder. - -### Option B — chevron-on-folders affordance -Add a `›` navigation button on folder rows to make "enter this folder" discoverable to mouse users. A permanent chevron is visual noise, so we show it only on row hover (and on keyboard focus for a11y parity). - -### Option C — add a filter input -Add a text input at the top of the list that live-filters visible entries by substring. No change to the selection model. - -### Chosen: Option C + Option B -The filter input (C) addresses complaint #1 and surfaces `Enter`-to-navigate for keyboard users. The hover/focus chevron (B) addresses complaint #2 for mouse users without the visual noise of a permanent affordance. The two are independent and compose cleanly. - -## Proposed change - -### 1. Filter input (addresses complaint #1) - -- Add a text input above the file list (below the breadcrumb bar) that auto-focuses on mount. The picker only mounts when the outer dialog opens (verified: `AddRepoSteps.tsx` conditionally renders `RemoteFileBrowser` based on dialog-open state), so the auto-focus cannot steal focus from the outer dialog's inputs. -- Live-filter `entries` by case-insensitive substring match on `entry.name`. **Filters both files and folders** — hiding files would confuse users trying to confirm they're in the right directory (e.g. looking for a README). Files remain non-actionable. -- Keyboard (handled on the input's `onKeyDown`): - - `↓` / `↑` — move the highlight (`selectedName`) through the *filtered* list. `preventDefault` so the caret doesn't jump. Clamps at the ends: ArrowUp at the first filtered entry (or with nothing highlighted) stays put; it never triggers parent-directory navigation. Parent-nav is exclusively the breadcrumb `↑` button. - - `Enter` — precedence: (a) if a folder is highlighted, navigate into it; (b) else if a **file** is highlighted, surface the transient footer hint (below) and do not navigate; (c) else if the filtered set contains exactly one folder (regardless of how many files are also in the set), navigate into that folder; (d) else highlight the first filtered entry (file or folder) — this is a highlight-only step and the visible highlight is the feedback; a subsequent `Enter` then re-enters this ladder and hits (a) or (b). Rule (b) is the only path that triggers the hint, so a filter that matches only files and is already highlighted on the first entry keeps yielding the hint on repeated `Enter`, never a silent no-op. The hint text is `Files can't be opened as a project`, shown in the footer for 2s before reverting. Chose the footer hint over an input shake/flash because it needs no animation infrastructure and the footer is already the dialog's status region. - - `Esc` — if filter is non-empty, clear it and `stopPropagation` so the outer dialog doesn't close; otherwise let the event bubble and call `onCancel`. -- **Focus management:** the input retains focus across row clicks. Clicking a row calls `setSelectedName` but does not steal focus (`onMouseDown` with `preventDefault` on the row buttons, or explicit `inputRef.current?.focus()` after selection). This keeps arrow keys and typing working after the user mouses. -- **Navigation helper.** Introduce a `navigate(path)` wrapper that calls `loadDir(path)` *and* clears the filter. All user-initiated navigation (breadcrumb segment click, the breadcrumb `↑` parent-directory button, double-click, chevron click, `Enter`-to-enter-folder) goes through `navigate`. The `↑`/`↓` ArrowUp/ArrowDown *keys* move the filter-list highlight per the keyboard spec above and do not call `navigate`. The mount effect calls `loadDir(path)` directly — it never clears the filter, so a user who types before the first load completes does not lose their input. This removes the `didInitialLoad` ref. -- When the filter changes, if the current `selectedName` is no longer in the filtered list, clear it so the button label doesn't go stale. -- **Empty-state copy.** When `entries.length > 0` but `filteredEntries.length === 0`, render `No matches for ''` instead of the generic `Empty directory` copy — the latter is misleading when the directory has contents that are simply filtered out. -- Placeholder: `Type to filter…` with a leading `Search` icon (lucide) for scannability, matching the existing icon-prefixed inputs in the sidebar. - -### 2. Dynamic button label (addresses complaint #3) - -Replace the static "Select" label with the path it will return: - -- When a row is highlighted: `Select /home/neil/myproject` -- When no row is highlighted: `Select /home/neil` (the current directory) -- **Left-ellipsis truncation.** Plain Tailwind `truncate` right-ellipsizes, which hides the meaningful tail. Use `direction: rtl; text-align: left;` on an inner span wrapping the path (keep the word "Select" in a separate LTR span), or equivalent CSS. Add the full path as a `title` attribute for hover-tooltip verification. Note: RTL-directionality on the path span can reorder punctuation in filenames that themselves contain RTL characters — accepted as a tiny edge-case risk for SSH target paths. - -This makes the two selection modes (child vs. current dir) visible without any model change, and tells the user exactly what will be opened. - -### 3. Footer - -Keep the existing muted full-path line — it's the unambiguous source of truth when the button label is ellipsized. Prepend a short hint so first-time users know what Select does: - -> Opens as a remote project · `/home/neil/myproject` - -Single line, muted, truncates with right-ellipsis (the prefix is the fixed part, the path tail can be cut since it's already in the button's `title`). - -When `Enter` is pressed on a highlighted file, swap this line for `Files can't be opened as a project` (same muted style) for 2s, then revert. Clear the hint's timer eagerly on any filter change or navigation so the message never outlives the state it describes. - -### 4. Folder-row chevron (addresses complaint #2) - -Folder rows render a `›` icon (lucide `ChevronRight`) on the right edge, shown only on row hover or keyboard focus. Files never render the chevron. - -- **Trigger:** CSS `:hover` on the row plus a `:focus-visible` rule so keyboard-focused rows also show it. Never always-visible — a permanent chevron is visual noise across a long list. -- **Click handler:** the chevron is a nested ` + + + + + +
+
+
+ + Orca +
+
+ +
+
Welcome back
+
+ +
+
+ +
294
+
Agents
+
+
+ +
1d 14h
+
Agent time
+
+
+ +
277
+
PRs
+
+
+ +
Desktops
+
+
+
+ +
+
+
+
+
Host 1
+
+
Connected
+
+
+ +
+
+ +
+
+ +
+
+
Pair another desktop
+
Scan a QR code from Orca desktop
+
+
+
+ +
+ +
+ + Settings +
+
+ + +
+
+
+ + Orca +
+
+ +
+
+ +
+ +
Welcome back
+
+ + +
+
+
+ +
+
294
+
Agents
+
+
+
+ +
+
1d 14h
+
Agent time
+
+
+
+ +
+
277
+
PRs
+
+
+ + +
Desktops
+
+
+
+ +
+
+
+
Host 1
+
+ 12 worktrees +
+ 3 active +
+
+
+ +
+
+ +
+
+ +
+
+
+
Work Laptop
+
+ Disconnected +
+ Last seen 2h ago +
+
+
+ +
+
+
+ + +
Resume
+
+
+ + +
+
+
fix-auth-middleware
+
+ + orca  ·  feat/auth-v2 +
+
+
+ +
+
+ + +
Quick Actions
+ + + +
Recent Activity
+
+
+
+
+
+
Agent completed: fix login validation
+
Host 1 · 12 min ago
+
+
+
+
+
+
PR #284 merged: update auth middleware
+
Host 1 · 1h ago
+
+
+
+
+
+
Agent started: refactor payment flow
+
Host 1 · 2h ago
+
+
+
+
+ +
+
+ + +
+
+
+ + Orca +
+
+ +
+
+ +
+ +
Welcome to Orca
+
+ +
+
+
+ + + + + + +
+
Connect your desktop
+
+ Pair with Orca on your computer to monitor worktrees, watch agents work, and manage terminals — all from your phone. +
+ +
+ +
+
How it works
+
+
1
+
+
Open Orca desktop
+
Go to Settings → Mobile and generate a pairing QR code.
+
+
+
+
2
+
+
Scan the code
+
Tap the button above to open the scanner. Point at the QR code on your screen.
+
+
+
+
3
+
+
You're connected
+
Your desktop will appear here. Everything is encrypted end-to-end.
+
+
+
+
+
+ + + + diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 00000000000..f058a6da01b --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,55 @@ +{ + "name": "orca-mobile", + "version": "0.0.1", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "lint": "oxlint", + "format": "oxfmt --write .", + "mock-server": "npx tsx scripts/mock-server.ts" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", + "expo": "^55.0.23", + "expo-build-properties": "^55.0.13", + "expo-camera": "^55.0.18", + "expo-clipboard": "^55.0.13", + "expo-constants": "^55.0.16", + "expo-crypto": "^55.0.14", + "expo-haptics": "^55.0.14", + "expo-linking": "^55.0.15", + "expo-notifications": "^55.0.22", + "expo-router": "^55.0.14", + "expo-secure-store": "^55.0.13", + "expo-splash-screen": "^55.0.20", + "expo-status-bar": "^55.0.6", + "lucide-react-native": "^1.14.0", + "react": "^19.2.6", + "react-native": "^0.83.9", + "react-native-gesture-handler": "^2.31.2", + "react-native-reanimated": "^4.3.0", + "react-native-safe-area-context": "^5.7.0", + "react-native-screens": "^4.24.0", + "react-native-svg": "^15.15.4", + "react-native-web": "^0.21.2", + "react-native-webview": "^13.16.1", + "react-native-worklets": "^0.8.3", + "tweetnacl": "^1.0.3", + "ws": "^8.20.0", + "zod": "~4.4.3", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-native": "^0.73.0", + "@types/ws": "^8.18.1", + "expo-module-scripts": "^55.0.2", + "oxfmt": "^0.47.0", + "oxlint": "^1.63.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml new file mode 100644 index 00000000000..15ccfae4061 --- /dev/null +++ b/mobile/pnpm-lock.yaml @@ -0,0 +1,12419 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@react-native-async-storage/async-storage': + specifier: ^2.2.0 + version: 2.2.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + expo: + specifier: ^55.0.23 + version: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-build-properties: + specifier: ^55.0.13 + version: 55.0.13(expo@55.0.23) + expo-camera: + specifier: ^55.0.18 + version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-clipboard: + specifier: ^55.0.13 + version: 55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-constants: + specifier: ^55.0.16 + version: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + expo-crypto: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.23) + expo-haptics: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.23) + expo-linking: + specifier: ^55.0.15 + version: 55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-notifications: + specifier: ^55.0.22 + version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-router: + specifier: ^55.0.14 + version: 55.0.14(021822249f1df9b3c5f483d3fca100f4) + expo-secure-store: + specifier: ^55.0.13 + version: 55.0.13(expo@55.0.23) + expo-splash-screen: + specifier: ^55.0.20 + version: 55.0.20(expo@55.0.23)(typescript@5.9.3) + expo-status-bar: + specifier: ^55.0.6 + version: 55.0.6(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + lucide-react-native: + specifier: ^1.14.0 + version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react: + specifier: ^19.2.6 + version: 19.2.6 + react-native: + specifier: ^0.83.9 + version: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-native-gesture-handler: + specifier: ^2.31.2 + version: 2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-reanimated: + specifier: ^4.3.0 + version: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-safe-area-context: + specifier: ^5.7.0 + version: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-screens: + specifier: ^4.24.0 + version: 4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-svg: + specifier: ^15.15.4 + version: 15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-web: + specifier: ^0.21.2 + version: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + react-native-webview: + specifier: ^13.16.1 + version: 13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-worklets: + specifier: ^0.8.3 + version: 0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 + ws: + specifier: ^8.20.0 + version: 8.20.0 + zod: + specifier: ~4.4.3 + version: 4.4.3 + zustand: + specifier: ^5.0.13 + version: 5.0.13(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-native': + specifier: ^0.73.0 + version: 0.73.0(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + expo-module-scripts: + specifier: ^55.0.2 + version: 55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.6.0))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + oxfmt: + specifier: ^0.47.0 + version: 0.47.0 + oxlint: + specifier: ^1.63.0 + version: 1.63.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@babel/cli@7.28.6': + resolution: {integrity: sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==} + engines: {node: '>=6.9.0'} + hasBin: true + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': + resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': + resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': + resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3': + resolution: {integrity: sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': + resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6': + resolution: {integrity: sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.27.1': + resolution: {integrity: sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.28.6': + resolution: {integrity: sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.28.6': + resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.27.1': + resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.28.6': + resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.28.6': + resolution: {integrity: sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.27.1': + resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.27.1': + resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.28.6': + resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.28.6': + resolution: {integrity: sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.28.6': + resolution: {integrity: sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.27.1': + resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.27.1': + resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.4': + resolution: {integrity: sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.27.1': + resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.27.1': + resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.28.6': + resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.27.1': + resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.27.1': + resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.0': + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.28.6': + resolution: {integrity: sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.27.1': + resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.0': + resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.27.1': + resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.27.1': + resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.27.1': + resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.28.6': + resolution: {integrity: sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6': + resolution: {integrity: sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.5': + resolution: {integrity: sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/preset-react@7.28.5': + resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@expo-google-fonts/material-symbols@0.4.34': + resolution: {integrity: sha512-PdwETUhvu1gHF1e8eIyEHnBJLq/dRNoTrT5yhsGUfGyRxH5pbm54dF3+QPknxwMKj0M1trN7PSelYz+yzlt3lA==} + + '@expo/cli@55.0.29': + resolution: {integrity: sha512-r2dXQ82e/3nwxS7faLRL6HBD8UWDo/IyptQ0Vg6Z5Bgyp2Kd24h8xPn3RHfY3LLJ3wfEXglf4E79/Dqkm1Z6WA==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@55.0.8': + resolution: {integrity: sha512-8WfWTRntTCcowfOS+tHdB0z98gKetTwktg4G5TWkCkXVa8Jt1NUnvzaaU4UHk2vbR2U4N84RyZJFizSwfF6C9g==} + + '@expo/config-types@55.0.5': + resolution: {integrity: sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==} + + '@expo/config@55.0.16': + resolution: {integrity: sha512-H5dpQv5TfyZDNheZAWO3SmP10diGWZwN5QOUsArkDJih0QKNtahQBOmrV2xbhgln/nrUGoy41U/ZIY/MEx63Ug==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/devtools@55.0.3': + resolution: {integrity: sha512-KoIDgo0NoXeWLsIcOdZqtAG/1LlsM+JL0DA3bo0vCYaOYTBLXi/ZvRBqa20Ub8D2vKLNa+FgRQW0gRg04Ps1Pg==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/dom-webview@55.0.5': + resolution: {integrity: sha512-lt3uxYOCk3wmWvtOOvsC35CKGbDAOx5C2EaY8SH1JVSfBzqmF8Cs0Xp1MPxncDPMyxpMiWx5SvvV/iLF1rJU4A==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/env@2.1.2': + resolution: {integrity: sha512-RJtGFfj/ygO/6zcVbV3cckHf4THcEkv5IZft1GjCB3dfT6axvzvIwXE9EiQqQYmGHcQ+ZrvC8xZcIhiHba0pYg==} + engines: {node: '>=20.12.0'} + + '@expo/fingerprint@0.16.7': + resolution: {integrity: sha512-BH8sicYOqZ1iBMwCVEGIz6uTTfylosjc49FoMmCYIzKOiYdiVehsfoYBwyfxwWIiya1VMhm1gv0cgOP8fxHpDw==} + hasBin: true + + '@expo/image-utils@0.8.14': + resolution: {integrity: sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==} + + '@expo/json-file@10.0.14': + resolution: {integrity: sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==} + + '@expo/local-build-cache-provider@55.0.12': + resolution: {integrity: sha512-Wqhe7ajt6lyIEQvqDC1zm0MQ1RqQLlM9awCepY9pz+tm9rvhuxGPZTSddWeD8k4kolinBlDbLDFnNi06XgaDWQ==} + + '@expo/log-box@55.0.11': + resolution: {integrity: sha512-JQHFLWkskIbJi6cxYMjErx8lQqfFJilDQLKmdTO3m3YkdmN9GE/CrzjOfVlCG0DGEGZJ90br0pGKvGPdXNsHKw==} + peerDependencies: + '@expo/dom-webview': ^55.0.5 + expo: '*' + react: '*' + react-native: '*' + + '@expo/log-box@55.0.12': + resolution: {integrity: sha512-f9ARS8J60cq3LLNdIqmUjYwyerBzVS5Ecp7KjIf3GOIPjW0571rkcwLz4/U18l/1DeSkSzIkYsNl2TC9oTdWaQ==} + peerDependencies: + '@expo/dom-webview': ^55.0.6 + expo: '*' + react: '*' + react-native: '*' + + '@expo/metro-config@55.0.20': + resolution: {integrity: sha512-dUv0simEyPbN2wbOjI+BdEZyXdghgCZD0+3rrA1WxXZN1lRofUx6g2+Nik2Qg61v/BXFrCTh8reYEzQPzHOhdQ==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-runtime@55.0.10': + resolution: {integrity: sha512-7v+ldTvMWRa1ml83Jel9W2f8qT/NZZWrlHaEjf29nb72JTEO50+Xac9PWLo+X3LCDAAuyYuBGKYXOJwfqxV0fQ==} + peerDependencies: + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@55.1.1': + resolution: {integrity: sha512-/wfXo5hTuAVpVLG/4hzlmD9NBGJkzkmBEMm/4VICajYRbj7y8OmqqPWbbymzHiBiHB6tI9BnsyXpQM6zVZEECg==} + + '@expo/npm-proofread@1.0.1': + resolution: {integrity: sha512-yDyBlNIgg+rKoKCOVM9ZyAtgqRx6gHw+JtmZTrYYW4auAfwS6kE/iInmCpVXPqRy0OhJHgXj6q5PCgWELLVMeg==} + hasBin: true + + '@expo/osascript@2.4.3': + resolution: {integrity: sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==} + engines: {node: '>=12'} + + '@expo/package-manager@1.10.5': + resolution: {integrity: sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==} + + '@expo/plist@0.5.3': + resolution: {integrity: sha512-jz5oPcPDd3fygwVxwSwmO6wodTwm0Qa14NUyPy0ka7H8sFmCtNZUI2+DzVe/EXjOhq1FbEjrwl89gdlWYOnVjQ==} + + '@expo/prebuild-config@55.0.17': + resolution: {integrity: sha512-Mcs+dg4Ripu0yCtzf66KZr18PehI1O8HxzJw+G5SUF8VWX+ic99aci1PltvmydWepLwTQL6ykmpXicAUA31IqA==} + peerDependencies: + expo: '*' + + '@expo/require-utils@55.0.5': + resolution: {integrity: sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/router-server@55.0.16': + resolution: {integrity: sha512-LvAdrm039nQBG+95+ff5Rc4CsBuoc/giDhjQrgxB9lKJqC/ZTq1xbwfEZFNq6yokX6fOCs/vlxdhmSkOjMIrvg==} + peerDependencies: + '@expo/metro-runtime': ^55.0.11 + expo: '*' + expo-constants: ^55.0.16 + expo-font: ^55.0.7 + expo-router: '*' + expo-server: ^55.0.9 + react: '*' + react-dom: '*' + react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + expo-router: + optional: true + react-dom: + optional: true + react-server-dom-webpack: + optional: true + + '@expo/schema-utils@55.0.4': + resolution: {integrity: sha512-65IdeeE8dAZR3n3J5Eq7LYiQ8BFGeEYCWPBCzycvafL7PkskbCyIclTQarRwf/HXFoRvezKCjaLwy/8v9Prk6g==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/spawn-async@1.7.2': + resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/vector-icons@15.1.1': + resolution: {integrity: sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==} + peerDependencies: + expo-font: '>=14.0.4' + react: '*' + react-native: '*' + + '@expo/ws-tunnel@1.0.6': + resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} + + '@expo/xcpretty@4.4.4': + resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} + hasBin: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/diff-sequences@30.3.0': + resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': + resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==} + + '@oxfmt/binding-android-arm-eabi@0.47.0': + resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.47.0': + resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.47.0': + resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.47.0': + resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.47.0': + resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': + resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.47.0': + resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.47.0': + resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.47.0': + resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.47.0': + resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.47.0': + resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.47.0': + resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.47.0': + resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.47.0': + resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.47.0': + resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.47.0': + resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.47.0': + resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.47.0': + resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.47.0': + resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.63.0': + resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.63.0': + resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.63.0': + resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.63.0': + resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.63.0': + resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.63.0': + resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.63.0': + resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.63.0': + resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.63.0': + resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.63.0': + resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.63.0': + resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.63.0': + resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.63.0': + resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.63.0': + resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.63.0': + resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-native-async-storage/async-storage@2.2.0': + resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.65 <1.0 + + '@react-native/assets-registry@0.83.9': + resolution: {integrity: sha512-9MrShFZWvHybyjN8nj/rKW53hLpPOXMdEF8FOdDXRNu8oQ396BLknCMFeSCFhAHiCDVqQfah5iIV8S1wqXnxFA==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-plugin-codegen@0.83.6': + resolution: {integrity: sha512-qfRXsHGeucT5c6mK+8Q7v4Ly3zmygfVmFlEtkiq7q07W1OTreld6nib4rJ/DBEeNiKBoBTuHjWliYGNuDjLFQA==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-plugin-codegen@0.85.2': + resolution: {integrity: sha512-5Dqn08kRTUIxPLYju9hExI0cR1ESX+P5tEv5yv0q0UZcisRTw0VB8iUWDIph2LdY1i5Dc8PIvuaWMRNCw3vnKg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-preset@0.83.6': + resolution: {integrity: sha512-4/fXFDUvGOObETZq4+SUFkafld6OGgQWut5cQiqVghlhCB5z/p2lVhPgEUr/aTxTzeS3AmN+ztC+GpYPQ7tsTw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/babel-preset@0.85.2': + resolution: {integrity: sha512-7d2yW23eKkVt0FbbnZLxqO7KybGLtQXOuvvcO1NUOYGtjzVh6ihNKn0TIHrhSNpMyHwYLDoiiuj95wLtcg3IwQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.83.6': + resolution: {integrity: sha512-doB/Pq6Cf6IjF3wlQXTIiZOnsX9X8mEEk+CdGfyuCwZjWrf7IB8KaZEXXckJmfUcIwvJ9u/a72ZoTTCIoxAc9A==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.83.9': + resolution: {integrity: sha512-fUliGYOD1D/hS4pZMK+YyGb01DxQQNUTjL+i2iL6elt2AncXoYMWNxLT/AMT38jImxI4FbKyH4JO+mL2+t5yuA==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.85.2': + resolution: {integrity: sha512-XCginmxh0//++EXVOEJHBVZxHla294FzLCFF6jXwAUjvXVhqyIKyxhABfz+r4OOmaiuWk4Rtd4arqdAzeHeprg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.83.9': + resolution: {integrity: sha512-i8WhXYSEIB7kq99n6ZROI1lCckVEwz+Ti/oAT34UUU494MuSvZfd0UWEQ5cFpglPpIKq11KETPwmznHYF6xURg==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': '*' + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.83.6': + resolution: {integrity: sha512-TyWXEpAjVundrc87fPWg91piOUg75+X9iutcfDe7cO3NrAEYCsl7Z09rKHuiAGkxfG9/rFD13dPsYIixUFkSFA==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-frontend@0.83.9': + resolution: {integrity: sha512-LEOJrHvVWnjHC+emTyybeHkrInrJBnchk3VEhW5qGMWhW3vSM421CrZSCKAvzy+fDg/viufwBfIJvwTdk066CA==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-shell@0.83.6': + resolution: {integrity: sha512-684TJMBCU0l0ZjJWzrnK0HH+ERaM9KLyxyArE1k7BrP+gVl4X9GO0Pi94RoInOxvW/nyV65sOU6Ip1F3ygS0cg==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-shell@0.83.9': + resolution: {integrity: sha512-vAqDG7+3HOz4VOSfccSNe06C1+evcp2AbMjoYOmOyeJtt5dCdmGUp0FICsnU7iuctsNXWnHqyhks9M0wn7zEzA==} + engines: {node: '>= 20.19.4'} + + '@react-native/dev-middleware@0.83.6': + resolution: {integrity: sha512-22xoddLTelpcVnF385SNH2hdP7X2av5pu7yRl/WnM5jBznbcl0+M9Ce94cj+WVeomsoUF/vlfuB0Ooy+RMlRiA==} + engines: {node: '>= 20.19.4'} + + '@react-native/dev-middleware@0.83.9': + resolution: {integrity: sha512-4rW8KGkfqmZ5x1CTr+GrPBTdLG58UNkkJNQ8Dd9xdiVrZ/94sYKHm4HOo+UKTyOP0SNbZpZejDrf/2JNiqNL4Q==} + engines: {node: '>= 20.19.4'} + + '@react-native/gradle-plugin@0.83.9': + resolution: {integrity: sha512-v+iag7ft+fBRP54GthOiR70qrp0azgvW8PhNg0KFYw52xin09LoC0rk96CY+Hj5M1oGoQovrk0bdji7ipH2pGg==} + engines: {node: '>= 20.19.4'} + + '@react-native/js-polyfills@0.83.9': + resolution: {integrity: sha512-6+OdNC3GSgNI3L31zo730AxdI3eXjwD74k0M11gpvXKTOX/NQuXe3On+K4yLFokD2BFr8bL6XaqxCniCUQ3Q+Q==} + engines: {node: '>= 20.19.4'} + + '@react-native/js-polyfills@0.85.2': + resolution: {integrity: sha512-esGEAmKVM40DV/yVmNljCKZTIeUo7qXqc+Hwffkv3TG+b3E24xyFovHrbP98gGxZr2ZsEyx+2sKLdXF5asY5nw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/metro-babel-transformer@0.85.2': + resolution: {integrity: sha512-lU9XOGahpHvQff30H5lnvh9RYbVwC1zpSHpl84E+7BD2zj0FvW+pD7MBh7CWbmbWmegjtAb+U/2bokXcDVA+jA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/metro-config@0.85.2': + resolution: {integrity: sha512-YkTIMfTPeyMUrtpQo/7zd3oybVYJCfTp8626PqoakOvEiWi9PxsUpZ8j44a5GFtOIq8Nc6WWVBiFRn/6qdi1uQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + + '@react-native/normalize-colors@0.83.6': + resolution: {integrity: sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw==} + + '@react-native/normalize-colors@0.83.9': + resolution: {integrity: sha512-GSXSvH+siDF2z+XtoF3Kk6zRFMWhGIpfq9nalBd4DSV97g5yNRQ5Htm2fpq7vBYNN+hVgvGZf7rRqnjn8bvfHg==} + + '@react-native/virtualized-lists@0.83.9': + resolution: {integrity: sha512-fZ84faVUANrBU7GfL1wPSkGkn5cnCPVXxAwutKit1S5i8hlfwCnt0gE9Fu/0fpAIeuGxHVed00w2xxDVXyLxKQ==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-navigation/bottom-tabs@7.15.11': + resolution: {integrity: sha512-+WtNbd6fJgbViDNjmBUUP7eTgGH+zBtrl3jHuNnfUfXTs9YGuI5q3SiHIc9a5gY3voBOxbOXEiHJyW4xea7nAw==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/core@7.17.2': + resolution: {integrity: sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==} + peerDependencies: + react: '>= 18.2.0' + + '@react-navigation/elements@2.9.15': + resolution: {integrity: sha512-cyz/pPiyyC6gaTVLsGFc1g0MYgrmuCFqklAWGXMWPscr5YU3ui94vPI4vnZwcsEy0T758TQWLzmS5XudZeRKcA==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + + '@react-navigation/native-stack@7.14.12': + resolution: {integrity: sha512-dUfpkrVeVKKV8iqXsmoUp3Rv0iH3YaB3eZwScru/FlcqAp/r3/qA6zEXkGX9hZK+/ziWAPFrf1frBSNbgOYSFQ==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/native@7.2.2': + resolution: {integrity: sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + + '@react-navigation/routers@7.5.3': + resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@testing-library/react-native@13.3.3': + resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==} + engines: {node: '>=18'} + peerDependencies: + jest: '>=29.0.0' + react: '>=18.2.0' + react-native: '>=0.71' + react-test-renderer: '>=18.2.0' + peerDependenciesMeta: + jest: + optional: true + + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + + '@tsconfig/node18@18.2.6': + resolution: {integrity: sha512-eAWQzAjPj18tKnDzmWstz4OyWewLUNBm9tdoN9LayzoboRktYx3Enk1ZXPmThj55L7c4VWYq/Bzq0A51znZfhw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/react-native@0.73.0': + resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} + deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed. + + '@types/react-test-renderer@19.1.0': + resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-dynamic-import-node@2.3.3: + resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.2: + resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} + + babel-plugin-syntax-hermes-parser@0.32.0: + resolution: {integrity: sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==} + + babel-plugin-syntax-hermes-parser@0.32.1: + resolution: {integrity: sha512-HgErPZTghW76Rkq9uqn5ESeiD97FbqpZ1V170T1RG2RDp+7pJVQV2pQJs7y5YzN0/gcT6GM5ci9apRnIwuyPdQ==} + + babel-plugin-syntax-hermes-parser@0.33.3: + resolution: {integrity: sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@55.0.21: + resolution: {integrity: sha512-anXoUZBcxydLdVs2L+r3bWKGUvZv2FtgOl8xRJ12i/YfKICBpwTGZWSTiEYTqBByZ6GkA3mE9+3TW97X2ocFTQ==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^55.0.17 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + barcode-detector@3.1.3: + resolution: {integrity: sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-opn@3.0.2: + resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} + engines: {node: '>=12.0.0'} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + char-regex@2.0.2: + resolution: {integrity: sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==} + engines: {node: '>=12.20'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.2.0: + resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dnssd-advertise@1.1.4: + resolution: {integrity: sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.352: + resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-config-universe@15.0.4: + resolution: {integrity: sha512-7XTb/JTLzntJTUHXnR7ADl78kzRpQLm75NOjx1kYFnEMArJk69mDJ96WREzttro4/TOlQ9paGL+WFsRXk1vLkw==} + peerDependencies: + eslint: '>=8.10' + prettier: '>=3' + peerDependenciesMeta: + prettier: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-es@3.0.1: + resolution: {integrity: sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=4.19.1' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-node@11.1.0: + resolution: {integrity: sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=5.16.0' + + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-utils@2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} + + eslint-visitor-keys@1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + expo-application@55.0.14: + resolution: {integrity: sha512-NgqDIt3eCf4aVLp1L6AcEanCYoyJeuBsGrgGSzOIvxAsOvp5X3SYKW3ROgpKUnLQEKMWlzwETpjsUGszcqkk8g==} + peerDependencies: + expo: '*' + + expo-asset@55.0.17: + resolution: {integrity: sha512-pK9HHJuFqjE8kDUcbMFsZj3Cz8WdXpvZHZmYl7ouFQp59P83BvHln6VnqPDGlO+/4929G0Lm8ZUzbONuNRhi9w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-build-properties@55.0.13: + resolution: {integrity: sha512-UYZhUKyh7YQhbJdkBvo68WUQ7fOtZeSV7F8kfYkjEiN/ADRHG0WfEIiddvGfi9cH/5iwpptv/+Lu5cx6uvfegA==} + peerDependencies: + expo: '*' + + expo-camera@55.0.18: + resolution: {integrity: sha512-Us/7JV6O1lHpLBGKJnK2s8gzmPcmMVJSV5586DBeO7x7AXzmvvVGtH+0nJRVIBE3MNzGzGWyfgievjr8QlE7dA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-clipboard@55.0.13: + resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-constants@55.0.16: + resolution: {integrity: sha512-Z15/No94UHoogD+pulxjudGAeOHTEIWZgb/vnX48Wx5D+apWTeCbnKxQZZtGQlosvduYL5kaic2/W8U+NHfBQQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-crypto@55.0.14: + resolution: {integrity: sha512-TfAADBGZNNv9OOmdKFJCz54wDj87ufxtzQNSY+Roycpm8e5tuCnDIL7EjqUOmNTGH99Jj8ftPGFt4KGG2Ii2fg==} + peerDependencies: + expo: '*' + + expo-file-system@55.0.19: + resolution: {integrity: sha512-c4smCbMqELLI3YQrGpw21MwZIREXM2e53vQD/+KWQcae1q+hgw8J2TroEqcQ/jVOtFpZYVvyVfgu4HDKNEKmNw==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@55.0.7: + resolution: {integrity: sha512-oH39Xb+3i6Y69b7YRP+P+5WLx7621t+ep/RAgLwJJYpTjs7CnSohUG+873rEtqsTAuQGi63ms7x9ZeHj1E9LYw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-glass-effect@55.0.11: + resolution: {integrity: sha512-wqq7GUOqSkfoFJzreZvBG0jzjsq5c582m3glhWSjcmIuByxXXWp6j6GY6hyFuYKzpOXhbuvusVxGCQi0yWnp3g==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-haptics@55.0.14: + resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==} + peerDependencies: + expo: '*' + + expo-image@55.0.10: + resolution: {integrity: sha512-We+vq/Z8jy8zmGxcOP8vrhiWkkwyXFdSks8cSlPi0bpu6D0Ei6l9Nj2xHWCD+yoENh92aCEe1+QRujAwXbogGA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-keep-awake@55.0.8: + resolution: {integrity: sha512-PfIpMfM+STOBwkR5XOE+yVtER86c44MD+W8QD8JxuO0sT9pF7Y1SJYakWlpvX8xsGA+bjKLxftm9403s9kQhKA==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@55.0.15: + resolution: {integrity: sha512-/RQh2vkNqV8Bim9Owm/evVqn2fqTvCDYHkpYPoSKbLAdydSGdHC2xZNw7Odl4wu1i1/3L4Xz//LKd3NsPWYWBQ==} + peerDependencies: + react: '*' + react-native: '*' + + expo-module-scripts@55.0.2: + resolution: {integrity: sha512-gtFQzs6wduZxYo4/1udcVLANDvQbVUVFjK1xJjgtZuUBlbJ+xFjQg5wOsQaffUDO3kqrG+DRXt1ihSxK72Wqkg==} + hasBin: true + + expo-modules-autolinking@55.0.21: + resolution: {integrity: sha512-P9KsJgOwI7JVwxmGfRvcXkXO4LNRvHRdWmb4ukLmX15G/vZ7b6SM17yiYkPceWq1F5KeeZ11KFjEcl0y17xy7w==} + hasBin: true + + expo-modules-core@55.0.25: + resolution: {integrity: sha512-yXpfg7aHLbuqoXocK34Vua6Aey5SCyqLygAsXAMbul9P8vfBjLpaOPiTJ5cLVF7Drfq8ownqVJO6qpGEtZ6GOw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: ^0.7.4 || ^0.8.0 + peerDependenciesMeta: + react-native-worklets: + optional: true + + expo-notifications@55.0.22: + resolution: {integrity: sha512-Rwvsp/lAEXfDYBxkQZpaLF9ZB25cJ/yfHhD/ESclbPesN0nbQBZ/5rGb1xS/saANtkStbEGfDlA80uHh2zEpsA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-router@55.0.14: + resolution: {integrity: sha512-rOn/wosp2hAPM+O2o41hnarbP5Zqv9UkHWa31KoSoiOme1tpmZd2yc93XtRAtzP0P5E5xzqq7a2rbEAarpP5XA==} + peerDependencies: + '@expo/log-box': 55.0.12 + '@expo/metro-runtime': ^55.0.11 + '@react-navigation/drawer': ^7.9.4 + '@testing-library/react-native': '>= 13.2.0' + expo: '*' + expo-constants: ^55.0.16 + expo-linking: ^55.0.15 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: '*' + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@react-navigation/drawer': + optional: true + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-secure-store@55.0.13: + resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==} + peerDependencies: + expo: '*' + + expo-server@55.0.9: + resolution: {integrity: sha512-N5Ipn1NwqaJzEm+G97o0Jbe4g/th3R/16N1DabnYryXKCiZwDkK13/w3VfGkQN9LOOaBP+JIRxGf4M8lQKPzyA==} + engines: {node: '>=20.16.0'} + + expo-splash-screen@55.0.20: + resolution: {integrity: sha512-WI5T0dutiZhxsqlF+jhEP4JRpQNILLlP8IpmKehsnV53Cncv6AQrKE7y1sOWwDyC2m2GBufZ/Vwam1RMt2EfmA==} + peerDependencies: + expo: '*' + + expo-status-bar@55.0.6: + resolution: {integrity: sha512-ijOUptfdiqYt7rObZ6jrPQ8sE5YN/8MxKCIJx0b7TY4nGkSJxhPIxeoW4GXcXCA8mTQ9PiOHH/ThLZgRVZvUlQ==} + peerDependencies: + react: '*' + react-native: '*' + + expo-symbols@55.0.8: + resolution: {integrity: sha512-Dg6BTu+fCWukdlh+3XYIr6NbqJWmK4aAQ6i6BInKnWU0ALuzVUJcMDq8Lk9bHok2hOh3OhzJqlCqEoBXPInIVQ==} + peerDependencies: + expo: '*' + expo-font: '*' + react: '*' + react-native: '*' + + expo@55.0.23: + resolution: {integrity: sha512-b+lKwfzJzFiSm9G0wVGWw3c2YoZyubbl9gHOF1ZFuK8FqtxSge8pDDJMuEFmTi14dbKwh/tirB7MiORq54r7CQ==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-native: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-nodeshim@0.4.10: + resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-readdir-recursive@1.1.0: + resolution: {integrity: sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hermes-compiler@0.14.1: + resolution: {integrity: sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==} + + hermes-estree@0.32.0: + resolution: {integrity: sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==} + + hermes-estree@0.32.1: + resolution: {integrity: sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==} + + hermes-estree@0.33.3: + resolution: {integrity: sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==} + + hermes-estree@0.35.0: + resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + + hermes-parser@0.32.0: + resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + + hermes-parser@0.32.1: + resolution: {integrity: sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==} + + hermes-parser@0.33.3: + resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} + + hermes-parser@0.35.0: + resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-diff@30.3.0: + resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-expo@55.0.17: + resolution: {integrity: sha512-LEJJyPBYtr7FzL5DUvnPsWF4ial6h23XQ37ikmqu/HFR4KidFkQ03ht2jsUWUSqmN/KdKeWSesEmbe5AUNZn3A==} + hasBin: true + peerDependencies: + expo: '*' + react-native: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + react-server-dom-webpack: + optional: true + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.3.0: + resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watch-select-projects@2.0.0: + resolution: {integrity: sha512-j00nW4dXc2NiCW6znXgFLF9g8PJ0zP25cpQ1xRro/HU2GBfZQFZD0SoXnAlaoKkIY4MlfTMkKGbNXFpvCdjl1w==} + + jest-watch-typeahead@2.2.1: + resolution: {integrity: sha512-jYpYmUnTzysmVnwq49TAxlmtOAwp8QIqvZyoofQFn8fiWhEDZj33ZXzg3JA4nGnzWFm1hbWf3ADpteUokvXgFA==} + engines: {node: ^14.17.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: ^27.0.0 || ^28.0.0 || ^29.0.0 + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lan-network@0.2.1: + resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react-native@1.14.0: + resolution: {integrity: sha512-FkOjd4JHIB8SplakHQjeo4RR/5peXtl+PbL+TghQqWzcQ+AKbJ/PFl5xEnerLtSQ4EIq6B9uXblY7QSyKg05WQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: '*' + react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + metro-babel-transformer@0.83.7: + resolution: {integrity: sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==} + engines: {node: '>=20.19.4'} + + metro-babel-transformer@0.84.4: + resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.83.7: + resolution: {integrity: sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==} + engines: {node: '>=20.19.4'} + + metro-cache-key@0.84.4: + resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.83.7: + resolution: {integrity: sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==} + engines: {node: '>=20.19.4'} + + metro-cache@0.84.4: + resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.83.7: + resolution: {integrity: sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==} + engines: {node: '>=20.19.4'} + + metro-config@0.84.4: + resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.83.7: + resolution: {integrity: sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==} + engines: {node: '>=20.19.4'} + + metro-core@0.84.4: + resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.83.7: + resolution: {integrity: sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==} + engines: {node: '>=20.19.4'} + + metro-file-map@0.84.4: + resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.83.7: + resolution: {integrity: sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==} + engines: {node: '>=20.19.4'} + + metro-minify-terser@0.84.4: + resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.83.7: + resolution: {integrity: sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==} + engines: {node: '>=20.19.4'} + + metro-resolver@0.84.4: + resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.83.7: + resolution: {integrity: sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==} + engines: {node: '>=20.19.4'} + + metro-runtime@0.84.4: + resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.83.7: + resolution: {integrity: sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==} + engines: {node: '>=20.19.4'} + + metro-source-map@0.84.4: + resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-symbolicate@0.83.7: + resolution: {integrity: sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro-symbolicate@0.84.4: + resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-transform-plugins@0.83.7: + resolution: {integrity: sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==} + engines: {node: '>=20.19.4'} + + metro-transform-plugins@0.84.4: + resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.83.7: + resolution: {integrity: sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==} + engines: {node: '>=20.19.4'} + + metro-transform-worker@0.84.4: + resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro@0.83.7: + resolution: {integrity: sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro@0.84.4: + resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multitars@1.0.0: + resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + ob1@0.83.7: + resolution: {integrity: sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==} + engines: {node: '>=20.19.4'} + + ob1@0.84.4: + resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxfmt@0.47.0: + resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + oxlint@1.63.0: + resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-format@30.3.0: + resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + + react-native-gesture-handler@2.31.2: + resolution: {integrity: sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.3.0: + resolution: {integrity: sha512-HOTTPdKtddXTOsmQxDASXEwLS3lqEHrKERD3XOgzSqWJ7L3x81Pnx7mTcKx1FKdkgomMug/XSmm1C6Z7GIowxA==} + peerDependencies: + react: '*' + react-native: 0.81 - 0.85 + react-native-worklets: 0.8.x + + react-native-safe-area-context@5.7.0: + resolution: {integrity: sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.24.0: + resolution: {integrity: sha512-SyoiGaDofiyGPFrUkn1oGsAzkRuX1JUvTD9YQQK3G1JGQ5VWkvHgYSsc1K9OrLsDQxN7NmV71O0sHCAh8cBetA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-svg@15.15.4: + resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-web@0.21.2: + resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-native-webview@13.16.1: + resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-worklets@0.8.3: + resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} + peerDependencies: + '@babel/core': '*' + '@react-native/metro-config': '*' + react: '*' + react-native: 0.81 - 0.85 + + react-native@0.83.9: + resolution: {integrity: sha512-bTQpaSJBbxOizI8SMNiCR/5aIvfO3ZlXAcWYxg6FmdbfJYCZ0zdUCK9mSZ4patureITGHNU94RXhxDAJhqsO7w==} + engines: {node: '>= 20.19.4'} + hasBin: true + peerDependencies: + '@types/react': ^19.1.1 + react: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-test-renderer@19.2.0: + resolution: {integrity: sha512-zLCFMHFE9vy/w3AxO0zNxy6aAupnCuLSVOJYDe/Tp+ayGI1f2PLQsFVPANSD42gdSbmYx5oN+1VWDhcXtq7hAQ==} + peerDependencies: + react: ^19.2.0 + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpp@3.2.0: + resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} + engines: {node: '>=8'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.1: + resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@2.0.0: + resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==} + engines: {node: '>=6'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.6: + resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-gps@3.1.2: + resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==} + + stacktrace-js@2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-length@5.0.1: + resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} + engines: {node: '>=12.20'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser@5.46.2: + resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toqr@0.1.1: + resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-jest@29.0.5: + resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url-minimum@0.1.1: + resolution: {integrity: sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@5.0.13: + resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zxing-wasm@3.0.3: + resolution: {integrity: sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + +snapshots: + + '@babel/cli@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@jridgewell/trace-mapping': 0.3.31 + commander: 6.2.1 + convert-source-map: 2.0.0 + fs-readdir-recursive: 1.1.0 + glob: 7.2.3 + make-dir: 2.1.0 + slash: 2.0.0 + optionalDependencies: + '@nicolo-ribaudo/chokidar-2': 2.1.8-no-fsevents.3 + chokidar: 3.6.0 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/preset-env@7.29.5(@babel/core@7.29.0)': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.3(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.0) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.0 + esutils: 2.0.3 + + '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@expo-google-fonts/material-symbols@0.4.34': {} + + '@expo/cli@55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 55.0.16(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/devcert': 1.2.1 + '@expo/env': 2.1.2 + '@expo/image-utils': 0.8.14(typescript@5.9.3) + '@expo/json-file': 10.0.14 + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/metro': 55.1.1 + '@expo/metro-config': 55.0.20(expo@55.0.23)(typescript@5.9.3) + '@expo/osascript': 2.4.3 + '@expo/package-manager': 1.10.5 + '@expo/plist': 0.5.3 + '@expo/prebuild-config': 55.0.17(expo@55.0.23)(typescript@5.9.3) + '@expo/require-utils': 55.0.5(typescript@5.9.3) + '@expo/router-server': 55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@expo/schema-utils': 55.0.4 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.4.4 + '@react-native/dev-middleware': 0.83.6 + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + dnssd-advertise: 1.1.4 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-server: 55.0.9 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.0 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.4 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + semver: 7.7.4 + send: 0.19.2 + slugify: 1.6.9 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.20.0 + zod: 3.25.76 + optionalDependencies: + expo-router: 55.0.14(021822249f1df9b3c5f483d3fca100f4) + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@55.0.8': + dependencies: + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.14 + '@expo/plist': 0.5.3 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.7.4 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/config-types@55.0.5': {} + + '@expo/config@55.0.16(typescript@5.9.3)': + dependencies: + '@expo/config-plugins': 55.0.8 + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.14 + '@expo/require-utils': 55.0.5(typescript@5.9.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.7.4 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/devcert@1.2.1': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + + '@expo/devtools@55.0.3(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + '@expo/dom-webview@55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + '@expo/env@2.1.2': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/fingerprint@0.16.7': + dependencies: + '@expo/env': 2.1.2 + '@expo/spawn-async': 1.7.2 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.5 + resolve-from: 5.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.8.14(typescript@5.9.3)': + dependencies: + '@expo/require-utils': 55.0.5(typescript@5.9.3) + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/json-file@10.0.14': + dependencies: + '@babel/code-frame': 7.29.0 + json5: 2.2.3 + + '@expo/local-build-cache-provider@55.0.12(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.16(typescript@5.9.3) + chalk: 4.1.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/log-box@55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + '@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + anser: 1.4.10 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + stacktrace-parser: 0.1.11 + + '@expo/log-box@55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + '@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + anser: 1.4.10 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + stacktrace-parser: 0.1.11 + + '@expo/metro-config@55.0.20(expo@55.0.23)(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@expo/config': 55.0.16(typescript@5.9.3) + '@expo/env': 2.1.2 + '@expo/json-file': 10.0.14 + '@expo/metro': 55.1.1 + '@expo/spawn-async': 1.7.2 + browserslist: 4.28.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.32.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.4.49 + resolve-from: 5.0.0 + optionalDependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + anser: 1.4.10 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + pretty-format: 29.7.0 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.5(react@19.2.6) + transitivePeerDependencies: + - '@expo/dom-webview' + + '@expo/metro@55.1.1': + dependencies: + metro: 0.83.7 + metro-babel-transformer: 0.83.7 + metro-cache: 0.83.7 + metro-cache-key: 0.83.7 + metro-config: 0.83.7 + metro-core: 0.83.7 + metro-file-map: 0.83.7 + metro-minify-terser: 0.83.7 + metro-resolver: 0.83.7 + metro-runtime: 0.83.7 + metro-source-map: 0.83.7 + metro-symbolicate: 0.83.7 + metro-transform-plugins: 0.83.7 + metro-transform-worker: 0.83.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/npm-proofread@1.0.1': + dependencies: + semver: 5.7.2 + + '@expo/osascript@2.4.3': + dependencies: + '@expo/spawn-async': 1.7.2 + + '@expo/package-manager@1.10.5': + dependencies: + '@expo/json-file': 10.0.14 + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.5.3': + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@55.0.17(expo@55.0.23)(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.16(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/config-types': 55.0.5 + '@expo/image-utils': 0.8.14(typescript@5.9.3) + '@expo/json-file': 10.0.14 + '@react-native/normalize-colors': 0.83.6 + debug: 4.4.3 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.7.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/require-utils@55.0.5(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + debug: 4.4.3 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-server: 55.0.9 + react: 19.2.6 + optionalDependencies: + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-router: 55.0.14(021822249f1df9b3c5f483d3fca100f4) + react-dom: 19.2.5(react@19.2.6) + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@55.0.4': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/spawn-async@1.7.2': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/vector-icons@15.1.1(expo-font@55.0.7)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + '@expo/ws-tunnel@1.0.6': {} + + '@expo/xcpretty@4.4.4': + dependencies: + '@babel/code-frame': 7.29.0 + chalk: 4.1.2 + js-yaml: 4.1.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@25.6.0) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/diff-sequences@30.3.0': {} + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 25.6.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 25.6.0 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.49 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.6.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': + optional: true + + '@oxfmt/binding-android-arm-eabi@0.47.0': + optional: true + + '@oxfmt/binding-android-arm64@0.47.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.47.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.47.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.47.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.47.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.47.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.47.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.47.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.47.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.47.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.47.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.63.0': + optional: true + + '@oxlint/binding-android-arm64@1.63.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.63.0': + optional: true + + '@oxlint/binding-darwin-x64@1.63.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.63.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.63.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.63.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.63.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.63.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.63.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.63.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.63.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-presence@1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-roving-focus@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-tabs@1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@react-native-async-storage/async-storage@2.2.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))': + dependencies: + merge-options: 3.0.4 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + '@react-native/assets-registry@0.83.9': {} + + '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.83.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-plugin-codegen@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.85.2(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-preset@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@react-native/babel-plugin-codegen': 0.83.6(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.32.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/babel-preset@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@react-native/babel-plugin-codegen': 0.85.2(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/codegen@0.83.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + glob: 7.2.3 + hermes-parser: 0.32.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + + '@react-native/codegen@0.83.9(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + glob: 7.2.3 + hermes-parser: 0.32.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + + '@react-native/codegen@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + hermes-parser: 0.33.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.16 + yargs: 17.7.2 + + '@react-native/community-cli-plugin@0.83.9(@react-native/metro-config@0.85.2(@babel/core@7.29.0))': + dependencies: + '@react-native/dev-middleware': 0.83.9 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.83.7 + metro-config: 0.83.7 + metro-core: 0.83.7 + semver: 7.7.4 + optionalDependencies: + '@react-native/metro-config': 0.85.2(@babel/core@7.29.0) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.83.6': {} + + '@react-native/debugger-frontend@0.83.9': {} + + '@react-native/debugger-shell@0.83.6': + dependencies: + cross-spawn: 7.0.6 + fb-dotslash: 0.5.8 + + '@react-native/debugger-shell@0.83.9': + dependencies: + cross-spawn: 7.0.6 + fb-dotslash: 0.5.8 + + '@react-native/dev-middleware@0.83.6': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.83.6 + '@react-native/debugger-shell': 0.83.6 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/dev-middleware@0.83.9': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.83.9 + '@react-native/debugger-shell': 0.83.9 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.83.9': {} + + '@react-native/js-polyfills@0.83.9': {} + + '@react-native/js-polyfills@0.85.2': {} + + '@react-native/metro-babel-transformer@0.85.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@react-native/babel-preset': 0.85.2(@babel/core@7.29.0) + hermes-parser: 0.33.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + '@react-native/metro-config@0.85.2(@babel/core@7.29.0)': + dependencies: + '@react-native/js-polyfills': 0.85.2 + '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.0) + metro-config: 0.84.4 + metro-runtime: 0.84.4 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/normalize-colors@0.74.89': {} + + '@react-native/normalize-colors@0.83.6': {} + + '@react-native/normalize-colors@0.83.9': {} + + '@react-native/virtualized-lists@0.83.9(@types/react@19.2.14)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@react-navigation/bottom-tabs@7.15.11(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@react-navigation/native': 7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + color: 4.2.3 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-native-safe-area-context: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-screens: 4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + sf-symbols-typescript: 2.2.0 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/core@7.17.2(react@19.2.6)': + dependencies: + '@react-navigation/routers': 7.5.3 + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.12 + query-string: 7.1.3 + react: 19.2.6 + react-is: 19.2.6 + use-latest-callback: 0.2.6(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) + + '@react-navigation/elements@2.9.15(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + '@react-navigation/native': 7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + color: 4.2.3 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-native-safe-area-context: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + use-latest-callback: 0.2.6(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) + + '@react-navigation/native-stack@7.14.12(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@react-navigation/native': 7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + color: 4.2.3 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-native-safe-area-context: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-screens: 4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + sf-symbols-typescript: 2.2.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + dependencies: + '@react-navigation/core': 7.17.2(react@19.2.6) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.12 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + use-latest-callback: 0.2.6(react@19.2.6) + + '@react-navigation/routers@7.5.3': + dependencies: + nanoid: 3.3.12 + + '@rtsao/scc@1.1.0': {} + + '@sinclair/typebox@0.27.10': {} + + '@sinclair/typebox@0.34.49': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)': + dependencies: + jest-matcher-utils: 30.3.0 + picocolors: 1.1.1 + pretty-format: 30.3.0 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-test-renderer: 19.2.0(react@19.2.6) + redent: 3.0.0 + optionalDependencies: + jest: 29.7.0(@types/node@25.6.0) + + '@tootallnate/once@2.0.1': {} + + '@tsconfig/node18@18.2.6': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/emscripten@1.41.5': {} + + '@types/estree@1.0.9': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 25.6.0 + + '@types/hammerjs@2.0.46': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 25.6.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/react-native@0.73.0(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - '@types/react' + - bufferutil + - react + - supports-color + - utf-8-validate + + '@types/react-test-renderer@19.1.0': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/stack-utils@2.0.3': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.0 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.1': {} + + '@xmldom/xmldom@0.8.13': {} + + '@xmldom/xmldom@0.9.10': {} + + abab@2.0.6: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-globals@7.0.1: + dependencies: + acorn: 8.16.0 + acorn-walk: 8.3.5 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + anser@1.4.10: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@6.2.1: {} + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asap@2.0.6: {} + + async-function@1.0.0: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + babel-jest@29.7.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-dynamic-import-node@2.3.3: + dependencies: + object.assign: 4.1.7 + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.0 + + babel-plugin-react-native-web@0.21.2: {} + + babel-plugin-syntax-hermes-parser@0.32.0: + dependencies: + hermes-parser: 0.32.0 + + babel-plugin-syntax-hermes-parser@0.32.1: + dependencies: + hermes-parser: 0.32.1 + + babel-plugin-syntax-hermes-parser@0.33.3: + dependencies: + hermes-parser: 0.33.3 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.0): + dependencies: + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-expo@55.0.21(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.23)(react-refresh@0.14.2): + dependencies: + '@babel/generator': 7.29.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/babel-preset': 0.83.6(@babel/core@7.29.0) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.32.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + debug: 4.4.3 + react-refresh: 0.14.2 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.29.2 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + + badgin@1.2.3: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + barcode-detector@3.1.3(@types/emscripten@1.41.5): + dependencies: + zxing-wasm: 3.0.3(@types/emscripten@1.41.5) + transitivePeerDependencies: + - '@types/emscripten' + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.27: {} + + better-opn@3.0.2: + dependencies: + open: 8.4.2 + + big-integer@1.6.52: {} + + binary-extensions@2.3.0: + optional: true + + boolbase@1.0.0: {} + + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.352 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001792: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + char-regex@2.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + optional: true + + chrome-launcher@0.15.2: + dependencies: + '@types/node': 25.6.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.2.0: + dependencies: + '@types/node': 25.6.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + rimraf: 3.0.2 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@6.2.1: {} + + commander@7.2.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + convert-source-map@2.0.0: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + create-jest@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.2.3: {} + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decode-uri-component@0.2.2: {} + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@2.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + detect-node-es@1.1.0: {} + + diff-sequences@29.6.3: {} + + dnssd-advertise@1.1.4: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.352: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.21.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-compat-utils@0.5.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + semver: 7.7.4 + + eslint-config-prettier@9.1.2(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-config-prettier: 9.1.2(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + eslint-plugin-node: 11.1.0(eslint@9.39.4) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) + eslint-plugin-react: 7.37.5(eslint@9.39.4) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) + globals: 16.5.0 + optionalDependencies: + prettier: 2.8.8 + transitivePeerDependencies: + - '@types/eslint' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + - typescript + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.2 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + transitivePeerDependencies: + - supports-color + + eslint-plugin-es-x@7.8.0(eslint@9.39.4): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + eslint: 9.39.4 + eslint-compat-utils: 0.5.1(eslint@9.39.4) + + eslint-plugin-es@3.0.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + eslint-utils: 2.1.0 + regexpp: 3.2.0 + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + hasown: 2.0.3 + is-core-module: 2.16.2 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-n@17.24.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + enhanced-resolve: 5.21.0 + eslint: 9.39.4 + eslint-plugin-es-x: 7.8.0(eslint@9.39.4) + get-tsconfig: 4.14.0 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.7.4 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-node@11.1.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + eslint-plugin-es: 3.0.1(eslint@9.39.4) + eslint-utils: 2.1.0 + ignore: 5.3.2 + minimatch: 3.1.5 + resolve: 1.22.12 + semver: 6.3.1 + + eslint-plugin-prettier@5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8): + dependencies: + eslint: 9.39.4 + prettier: 2.8.8 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 + optionalDependencies: + eslint-config-prettier: 9.1.2(eslint@9.39.4) + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-plugin-react@7.37.5(eslint@9.39.4): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4 + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-utils@2.1.0: + dependencies: + eslint-visitor-keys: 1.3.0 + + eslint-visitor-keys@1.3.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expo-application@55.0.14(expo@55.0.23): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-asset@55.0.17(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.8.14(typescript@5.9.3) + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + transitivePeerDependencies: + - supports-color + - typescript + + expo-build-properties@55.0.13(expo@55.0.23): + dependencies: + '@expo/schema-utils': 55.0.4 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.7.4 + + expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + barcode-detector: 3.1.3(@types/emscripten@1.41.5) + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@types/emscripten' + + expo-clipboard@55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + expo-constants@55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)): + dependencies: + '@expo/env': 2.1.2 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + transitivePeerDependencies: + - supports-color + + expo-crypto@55.0.14(expo@55.0.23): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-file-system@55.0.19(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + expo-font@55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + fontfaceobserver: 2.3.0 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + expo-glass-effect@55.0.11(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + expo-haptics@55.0.14(expo@55.0.23): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-image@55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + sf-symbols-typescript: 2.2.0 + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + + expo-keep-awake@55.0.8(expo@55.0.23)(react@19.2.6): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react: 19.2.6 + + expo-linking@55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + transitivePeerDependencies: + - expo + - supports-color + + expo-module-scripts@55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.6.0))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/cli': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/preset-env': 7.29.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@expo/npm-proofread': 1.0.1 + '@expo/spawn-async': 1.7.2 + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@tsconfig/node18': 18.2.6 + '@types/jest': 29.5.14 + babel-plugin-dynamic-import-node: 2.3.3 + babel-preset-expo: 55.0.21(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.23)(react-refresh@0.14.2) + commander: 12.1.0 + eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) + glob: 13.0.6 + jest-expo: 55.0.17(@babel/core@7.29.0)(expo@55.0.23)(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + jest-snapshot-prettier: prettier@2.8.8 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.6.0)) + resolve-workspace-root: 2.0.1 + ts-jest: 29.0.5(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@29.7.0(@types/node@25.6.0))(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - '@babel/core' + - '@babel/runtime' + - '@jest/types' + - '@types/eslint' + - babel-jest + - bufferutil + - canvas + - esbuild + - eslint + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - expo + - expo-widgets + - jest + - prettier + - react + - react-native + - react-refresh + - react-server-dom-webpack + - react-test-renderer + - supports-color + - utf-8-validate + + expo-modules-autolinking@55.0.21(typescript@5.9.3): + dependencies: + '@expo/require-utils': 55.0.5(typescript@5.9.3) + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + commander: 7.2.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-modules-core@55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + react-native-worklets: 0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + + expo-notifications@55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.8.14(typescript@5.9.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-application: 55.0.14(expo@55.0.23) + expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + transitivePeerDependencies: + - supports-color + - typescript + + expo-router@55.0.14(021822249f1df9b3c5f483d3fca100f4): + dependencies: + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/schema-utils': 55.0.4 + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + '@react-navigation/bottom-tabs': 7.15.11(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@react-navigation/native': 7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@react-navigation/native-stack': 7.14.12(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + expo-glass-effect: 55.0.11(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-image: 55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-linking: 55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-server: 55.0.9 + expo-symbols: 55.0.8(expo-font@55.0.7)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.12 + query-string: 7.1.3 + react: 19.2.6 + react-fast-compare: 3.2.2 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-safe-area-context: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-screens: 4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.2.6) + vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + optionalDependencies: + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + react-dom: 19.2.5(react@19.2.6) + react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - expo-font + - supports-color + + expo-secure-store@55.0.13(expo@55.0.23): + dependencies: + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + + expo-server@55.0.9: {} + + expo-splash-screen@55.0.20(expo@55.0.23)(typescript@5.9.3): + dependencies: + '@expo/prebuild-config': 55.0.17(expo@55.0.23)(typescript@5.9.3) + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + + expo-status-bar@55.0.6(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + + expo-symbols@55.0.8(expo-font@55.0.7)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.34 + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + sf-symbols-typescript: 2.2.0 + + expo@55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + '@expo/cli': 55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + '@expo/config': 55.0.16(typescript@5.9.3) + '@expo/config-plugins': 55.0.8 + '@expo/devtools': 55.0.3(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/fingerprint': 0.16.7 + '@expo/local-build-cache-provider': 55.0.12(typescript@5.9.3) + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/metro': 55.1.1 + '@expo/metro-config': 55.0.20(expo@55.0.23)(typescript@5.9.3) + '@expo/vector-icons': 15.1.1(expo-font@55.0.7)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@ungap/structured-clone': 1.3.1 + babel-preset-expo: 55.0.21(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@55.0.23)(react-refresh@0.14.2) + expo-asset: 55.0.17(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + expo-file-system: 55.0.19(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)) + expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-keep-awake: 55.0.8(expo@55.0.23)(react@19.2.6) + expo-modules-autolinking: 55.0.21(typescript@5.9.3) + expo-modules-core: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + pretty-format: 29.7.0 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.1 + optionalDependencies: + '@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-webview: 13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-dom + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + exponential-backoff@3.1.3: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fb-dotslash@0.5.8: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-nodeshim@0.4.10: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + flow-enums-runtime@0.0.6: {} + + fontfaceobserver@2.3.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fresh@0.5.2: {} + + fs-readdir-recursive@1.1.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + getenv@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hermes-compiler@0.14.1: {} + + hermes-estree@0.32.0: {} + + hermes-estree@0.32.1: {} + + hermes-estree@0.33.3: {} + + hermes-estree@0.35.0: {} + + hermes-parser@0.32.0: + dependencies: + hermes-estree: 0.32.0 + + hermes-parser@0.32.1: + dependencies: + hermes-estree: 0.32.1 + + hermes-parser@0.33.3: + dependencies: + hermes-estree: 0.33.3 + + hermes-parser@0.35.0: + dependencies: + hermes-estree: 0.35.0 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + hyphenate-style-name@1.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + image-size@1.2.1: + dependencies: + queue: 6.0.2 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.4: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + optional: true + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.6.0) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@25.6.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.3.0: + dependencies: + '@jest/diff-sequences': 30.3.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.3.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-expo@55.0.17(@babel/core@7.29.0)(expo@55.0.23)(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + dependencies: + '@expo/config': 55.0.16(typescript@5.9.3) + '@expo/json-file': 10.0.14 + '@jest/create-cache-key-function': 29.7.0 + '@jest/globals': 29.7.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + jest-environment-jsdom: 29.7.0 + jest-snapshot: 29.7.0 + jest-watch-select-projects: 2.0.0 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.6.0)) + json5: 2.2.3 + lodash: 4.18.1 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-test-renderer: 19.2.0(react@19.2.6) + server-only: 0.0.1 + stacktrace-js: 2.0.2 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - canvas + - jest + - react + - supports-color + - typescript + - utf-8-validate + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 25.6.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.3.0 + pretty-format: 30.3.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watch-select-projects@2.0.0: + dependencies: + ansi-escapes: 4.3.2 + chalk: 3.0.0 + prompts: 2.4.2 + + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@25.6.0)): + dependencies: + ansi-escapes: 6.2.1 + chalk: 4.1.2 + jest: 29.7.0(@types/node@25.6.0) + jest-regex-util: 29.6.3 + jest-watcher: 29.7.0 + slash: 5.1.0 + string-length: 5.0.1 + strip-ansi: 7.2.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.6.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 25.6.0 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@25.6.0) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jimp-compact@0.16.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.16.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.5 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.20.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + lan-network@0.2.1: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.debounce@4.0.8: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.throttle@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.3.6: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react-native@1.14.0(react-native-svg@15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-native-svg: 15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + marky@1.3.0: {} + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.14: {} + + memoize-one@5.2.1: {} + + memoize-one@6.0.0: {} + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + metro-babel-transformer@0.83.7: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.83.7 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-babel-transformer@0.84.4: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.4 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache-key@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.83.7: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.83.7 + transitivePeerDependencies: + - supports-color + + metro-cache@0.84.4: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.4 + transitivePeerDependencies: + - supports-color + + metro-config@0.83.7: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.83.7 + metro-cache: 0.83.7 + metro-core: 0.83.7 + metro-runtime: 0.83.7 + yaml: 2.8.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-config@0.84.4: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.4 + metro-cache: 0.84.4 + metro-core: 0.84.4 + metro-runtime: 0.84.4 + yaml: 2.8.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.83.7 + + metro-core@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.4 + + metro-file-map@0.83.7: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-file-map@0.84.4: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.2 + + metro-minify-terser@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.2 + + metro-resolver@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-resolver@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.83.7: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.4: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.83.7: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.83.7 + nullthrows: 1.1.1 + ob1: 0.83.7 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-source-map@0.84.4: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.4 + nullthrows: 1.1.1 + ob1: 0.84.4 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.83.7 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.4 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.83.7: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.4: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.83.7: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.83.7 + metro-babel-transformer: 0.83.7 + metro-cache: 0.83.7 + metro-cache-key: 0.83.7 + metro-minify-terser: 0.83.7 + metro-source-map: 0.83.7 + metro-transform-plugins: 0.83.7 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-transform-worker@0.84.4: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.84.4 + metro-babel-transformer: 0.84.4 + metro-cache: 0.84.4 + metro-cache-key: 0.84.4 + metro-minify-terser: 0.84.4 + metro-source-map: 0.84.4 + metro-transform-plugins: 0.84.4 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.83.7: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.83.7 + metro-cache: 0.83.7 + metro-cache-key: 0.83.7 + metro-config: 0.83.7 + metro-core: 0.83.7 + metro-file-map: 0.83.7 + metro-resolver: 0.83.7 + metro-runtime: 0.83.7 + metro-source-map: 0.83.7 + metro-symbolicate: 0.83.7 + metro-transform-plugins: 0.83.7 + metro-transform-worker: 0.83.7 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.4: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.4 + metro-cache: 0.84.4 + metro-cache-key: 0.84.4 + metro-config: 0.84.4 + metro-core: 0.84.4 + metro-file-map: 0.84.4 + metro-resolver: 0.84.4 + metro-runtime: 0.84.4 + metro-source-map: 0.84.4 + metro-symbolicate: 0.84.4 + metro-transform-plugins: 0.84.4 + metro-transform-worker: 0.84.4 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@1.2.0: {} + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multitars@1.0.0: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-forge@1.4.0: {} + + node-int64@0.4.0: {} + + node-releases@2.0.38: {} + + normalize-path@3.0.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.7.4 + validate-npm-package-name: 5.0.1 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nullthrows@1.1.1: {} + + nwsapi@2.2.23: {} + + ob1@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + + ob1@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxfmt@0.47.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.47.0 + '@oxfmt/binding-android-arm64': 0.47.0 + '@oxfmt/binding-darwin-arm64': 0.47.0 + '@oxfmt/binding-darwin-x64': 0.47.0 + '@oxfmt/binding-freebsd-x64': 0.47.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.47.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.47.0 + '@oxfmt/binding-linux-arm64-gnu': 0.47.0 + '@oxfmt/binding-linux-arm64-musl': 0.47.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.47.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.47.0 + '@oxfmt/binding-linux-riscv64-musl': 0.47.0 + '@oxfmt/binding-linux-s390x-gnu': 0.47.0 + '@oxfmt/binding-linux-x64-gnu': 0.47.0 + '@oxfmt/binding-linux-x64-musl': 0.47.0 + '@oxfmt/binding-openharmony-arm64': 0.47.0 + '@oxfmt/binding-win32-arm64-msvc': 0.47.0 + '@oxfmt/binding-win32-ia32-msvc': 0.47.0 + '@oxfmt/binding-win32-x64-msvc': 0.47.0 + + oxlint@1.63.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.63.0 + '@oxlint/binding-android-arm64': 1.63.0 + '@oxlint/binding-darwin-arm64': 1.63.0 + '@oxlint/binding-darwin-x64': 1.63.0 + '@oxlint/binding-freebsd-x64': 1.63.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 + '@oxlint/binding-linux-arm-musleabihf': 1.63.0 + '@oxlint/binding-linux-arm64-gnu': 1.63.0 + '@oxlint/binding-linux-arm64-musl': 1.63.0 + '@oxlint/binding-linux-ppc64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-musl': 1.63.0 + '@oxlint/binding-linux-s390x-gnu': 1.63.0 + '@oxlint/binding-linux-x64-gnu': 1.63.0 + '@oxlint/binding-linux-x64-musl': 1.63.0 + '@oxlint/binding-openharmony-arm64': 1.63.0 + '@oxlint/binding-win32-arm64-msvc': 1.63.0 + '@oxlint/binding-win32-ia32-msvc': 1.63.0 + '@oxlint/binding-win32-x64-msvc': 1.63.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.6 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@4.0.1: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pngjs@3.4.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.4.49: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@2.8.8: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.3.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + proc-log@4.2.0: {} + + progress@2.0.3: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + promise@8.3.0: + dependencies: + asap: 2.0.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + querystringify@2.2.0: {} + + queue@6.0.2: + dependencies: + inherits: 2.0.4 + + range-parser@1.2.1: {} + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.8.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-dom@19.2.5(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.2.6): + dependencies: + react: 19.2.6 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-is@19.2.6: {} + + react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + '@egjs/hammerjs': 2.0.17 + '@types/react-test-renderer': 19.1.0 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + react-native-is-edge-to-edge@1.3.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + react-native-reanimated@4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react-native-worklets: 0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + semver: 7.7.4 + + react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-freeze: 1.0.4(react@19.2.6) + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + warn-once: 0.1.1 + + react-native-svg@15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + warn-once: 0.1.1 + + react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + + react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + + react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/metro-config': 0.85.2(@babel/core@7.29.0) + convert-source-map: 2.0.0 + react: 19.2.6 + react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6) + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.83.9 + '@react-native/codegen': 0.83.9(@babel/core@7.29.0) + '@react-native/community-cli-plugin': 0.83.9(@react-native/metro-config@0.85.2(@babel/core@7.29.0)) + '@react-native/gradle-plugin': 0.83.9 + '@react-native/js-polyfills': 0.83.9 + '@react-native/normalize-colors': 0.83.9 + '@react-native/virtualized-lists': 0.83.9(@types/react@19.2.14)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.32.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + hermes-compiler: 0.14.1 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.7 + metro-source-map: 0.83.7 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.6 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.7.4 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 7.5.10 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-refresh@0.14.2: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + get-nonce: 1.0.1 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-test-renderer@19.2.0(react@19.2.6): + dependencies: + react: 19.2.6 + react-is: 19.2.6 + scheduler: 0.27.0 + + react@19.2.6: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + optional: true + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpp@3.2.0: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.1: + dependencies: + jsesc: 3.1.0 + + require-directory@2.1.1: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-workspace-root@2.0.1: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.6.3: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@2.1.0: {} + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sisteransi@1.0.5: {} + + slash@2.0.0: {} + + slash@3.0.0: {} + + slash@5.1.0: {} + + slugify@1.6.9: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.6: {} + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + split-on-first@1.1.0: {} + + sprintf-js@1.0.3: {} + + stack-generator@2.0.10: + dependencies: + stackframe: 1.3.4 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackframe@1.3.4: {} + + stacktrace-gps@3.1.2: + dependencies: + source-map: 0.5.6 + stackframe: 1.3.4 + + stacktrace-js@2.0.2: + dependencies: + error-stack-parser: 2.1.4 + stack-generator: 2.0.10 + stacktrace-gps: 3.1.2 + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stream-buffers@2.2.0: {} + + strict-uri-encode@2.0.0: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-length@5.0.1: + dependencies: + char-regex: 2.0.2 + strip-ansi: 7.2.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + structured-headers@0.4.1: {} + + styleq@0.1.3: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tagged-tag@1.0.0: {} + + tapable@2.3.3: {} + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser@5.46.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + throat@5.0.0: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toqr@0.1.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@0.0.3: {} + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-jest@29.0.5(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@29.7.0(@types/node@25.6.0))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + tweetnacl@1.0.3: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@5.9.3: {} + + ua-parser-js@1.0.41: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.19.2: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + universalify@0.2.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-latest-callback@0.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + + utils-merge@1.0.1: {} + + uuid@7.0.3: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.5(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vlq@1.0.1: {} + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-fetch@3.6.20: {} + + whatwg-mimetype@3.0.0: {} + + whatwg-url-minimum@0.1.1: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@7.5.10: {} + + ws@8.20.0: {} + + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + + xml-name-validator@4.0.0: {} + + xml2js@0.6.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.8.4: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} + + zod@4.4.3: {} + + zustand@5.0.13(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + + zxing-wasm@3.0.3(@types/emscripten@1.41.5): + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.6.0 diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts new file mode 100644 index 00000000000..14c7ab2c3db --- /dev/null +++ b/mobile/scripts/mock-server.ts @@ -0,0 +1,321 @@ +#!/usr/bin/env npx tsx +// Why: standalone mock WebSocket server for developing the mobile app without +// a running Orca desktop instance. Responds to the same RPC methods the real +// runtime exposes, with realistic fake data. Supports E2EE handshake. +import { WebSocketServer, type WebSocket } from 'ws' +import nacl from 'tweetnacl' + +const PORT = Number(process.env.PORT) || 6768 +const AUTH_TOKEN = 'mock-device-token' + +// Why: generate a persistent server keypair for this mock session. +// The public key is printed at startup so it can be used in pairing QR data. +const serverKeyPair = nacl.box.keyPair() +const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64') + +type E2EEState = { + sharedKey: Uint8Array + deviceToken: string | null + authenticated: boolean +} + +function deriveSharedKey(ourSecret: Uint8Array, peerPublic: Uint8Array): Uint8Array { + return nacl.box.before(peerPublic, ourSecret) +} + +function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const msg = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(msg, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return Buffer.from(bundle).toString('base64') +} + +function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) return null + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + if (!plaintext) return null + return new TextDecoder().decode(plaintext) +} + +const FAKE_WORKTREES = [ + { + worktreeId: 'repo-1::/home/user/projects/acme-api', + repoId: 'repo-1', + repo: 'acme-api', + path: '/home/user/projects/acme-api', + branch: 'feature/auth-refactor', + linkedIssue: 42, + unread: true, + liveTerminalCount: 2, + hasAttachedPty: true, + lastOutputAt: Date.now() - 5000, + preview: '$ claude "refactor the auth module"' + }, + { + worktreeId: 'repo-1::/home/user/projects/acme-web', + repoId: 'repo-1', + repo: 'acme-web', + path: '/home/user/projects/acme-web', + branch: 'main', + linkedIssue: null, + unread: false, + liveTerminalCount: 1, + hasAttachedPty: true, + lastOutputAt: Date.now() - 60000, + preview: '$ npm test\nAll tests passed.' + } +] + +const FAKE_TERMINALS = [ + { + handle: 'term-1', + worktreeId: 'repo-1::/home/user/projects/acme-api', + title: 'Claude — auth refactor', + isActive: true, + hasRunningProcess: true + }, + { + handle: 'term-2', + worktreeId: 'repo-1::/home/user/projects/acme-api', + title: 'zsh', + isActive: false, + hasRunningProcess: false + } +] + +const FAKE_SCROLLBACK = [ + '$ claude "refactor the auth module to use JWT tokens"', + '', + '⏳ Working on it...', + '', + "I'll refactor the auth module. Here's my plan:", + '1. Replace session-based auth with JWT', + '2. Add token refresh endpoint', + '3. Update middleware', + '', + 'Let me start by reading the current auth module...', + '' +].join('\n') + +const STREAMING_CHUNKS = [ + 'Reading src/auth/middleware.ts...\n', + 'Reading src/auth/session.ts...\n', + '\nI see the current implementation uses express-session.\n', + "I'll replace it with jsonwebtoken.\n", + '\nUpdating src/auth/middleware.ts...\n' +] + +type RpcRequest = { + id: string + method: string + deviceToken?: string + params?: Record +} + +type RpcResponse = { + id: string + ok: boolean + result?: unknown + error?: { code: string; message: string } + streaming?: true + _meta: { runtimeId: string } +} + +function success(id: string, result: unknown, streaming?: boolean): RpcResponse { + const resp: RpcResponse = { id, ok: true, result, _meta: { runtimeId: 'mock-runtime' } } + if (streaming) { + resp.streaming = true + } + return resp +} + +function error(id: string, code: string, message: string): RpcResponse { + return { id, ok: false, error: { code, message }, _meta: { runtimeId: 'mock-runtime' } } +} + +function handleRequest( + request: RpcRequest, + send: (response: RpcResponse) => void, + ws: WebSocket +): void { + switch (request.method) { + case 'status.get': + send( + success(request.id, { + runtimeId: 'mock-runtime', + graphStatus: 'ready', + windowCount: 1, + tabCount: 2, + terminalCount: 2 + }) + ) + break + + case 'worktree.ps': + send( + success(request.id, { + worktrees: FAKE_WORKTREES, + totalCount: FAKE_WORKTREES.length, + truncated: false + }) + ) + break + + case 'terminal.list': + send( + success(request.id, { + terminals: FAKE_TERMINALS, + totalCount: FAKE_TERMINALS.length, + truncated: false + }) + ) + break + + case 'terminal.subscribe': { + send(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) + + let chunkIndex = 0 + const interval = setInterval(() => { + if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) { + clearInterval(interval) + if (ws.readyState === ws.OPEN) { + send(success(request.id, { type: 'end' })) + } + return + } + send(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) + chunkIndex++ + }, 500) + break + } + + case 'terminal.send': + send(success(request.id, { send: { handle: 'term-1', ok: true } })) + break + + case 'terminal.unsubscribe': + send(success(request.id, { unsubscribed: true })) + break + + default: + send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`)) + } +} + +const wss = new WebSocketServer({ port: PORT }) + +// Why: each connection goes through an E2EE handshake before any RPC traffic. +// The first message must be e2ee_hello (plaintext), then all subsequent +// messages are encrypted with the derived shared key. +const connectionState = new Map() + +wss.on('connection', (ws) => { + console.log('[mock] Client connected — waiting for e2ee_hello') + + ws.on('message', (data) => { + const msg = typeof data === 'string' ? data : data.toString('utf-8') + const e2ee = connectionState.get(ws) + + if (!e2ee) { + // Handshake phase — expect e2ee_hello + let hello: { type?: string; publicKeyB64?: string } + try { + hello = JSON.parse(msg) + } catch { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid JSON' })) + ws.close() + return + } + + if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Expected e2ee_hello' })) + ws.close() + return + } + + const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) + if (clientPublicKey.length !== 32) { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid public key' })) + ws.close() + return + } + + const sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) + connectionState.set(ws, { sharedKey, deviceToken: null, authenticated: false }) + + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + console.log('[mock] E2EE key exchange complete — waiting for encrypted auth') + return + } + + // Post-handshake — decrypt, handle, encrypt reply + const plaintext = e2eeDecrypt(msg, e2ee.sharedKey) + if (plaintext === null) { + console.log('[mock] Decryption failed — dropping message') + return + } + + let request: RpcRequest + try { + request = JSON.parse(plaintext) as RpcRequest + } catch { + const encrypted = e2eeEncrypt( + JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON')), + e2ee.sharedKey + ) + ws.send(encrypted) + return + } + + if (!e2ee.authenticated) { + const auth = request as unknown as { type?: string; deviceToken?: string } + if (auth.type !== 'e2ee_auth' || auth.deviceToken !== AUTH_TOKEN) { + ws.send( + e2eeEncrypt( + JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } }), + e2ee.sharedKey + ) + ) + ws.close() + return + } + e2ee.deviceToken = auth.deviceToken + e2ee.authenticated = true + ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), e2ee.sharedKey)) + console.log('[mock] E2EE authentication complete') + return + } + + console.log(`[mock] ${request.method} (id: ${request.id})`) + handleRequest( + request, + (response) => { + if (ws.readyState === ws.OPEN) { + ws.send(e2eeEncrypt(JSON.stringify(response), e2ee.sharedKey)) + } + }, + ws + ) + }) + + ws.on('close', () => { + connectionState.delete(ws) + console.log('[mock] Client disconnected') + }) + + ws.on('error', () => { + connectionState.delete(ws) + ws.close() + }) +}) + +console.log(`[mock] Orca mock server listening on ws://localhost:${PORT}`) +console.log(`[mock] Auth token: ${AUTH_TOKEN}`) +console.log(`[mock] Server public key (base64): ${serverPublicKeyB64}`) +console.log(`[mock] E2EE enabled — clients must send e2ee_hello before RPC`) diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts new file mode 100644 index 00000000000..89f0bb55d56 --- /dev/null +++ b/mobile/scripts/repro-terminal-colors.ts @@ -0,0 +1,362 @@ +/** + * Minimal mobile terminal color repro. + * + * Captures terminal.subscribe snapshots in the same sequence that tab switching + * uses: terminal A, terminal B, terminal A again. The report tells us whether + * ANSI SGR color attributes disappeared in the desktop serialized scrollback + * or are still present and therefore being lost during mobile WebView replay. + * + * Usage: + * ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \ + * pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB] + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const token = process.argv[2] +const serverPublicKeyB64 = process.argv[3] +const worktreeSelector = process.argv[4] +const explicitHandleA = process.argv[5] +const explicitHandleB = process.argv[6] +const ESC = String.fromCharCode(27) + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type Snapshot = { + label: string + handle: string + cols: number | null + rows: number | null + serialized: string + lines: string +} + +if (!token || !serverPublicKeyB64 || !worktreeSelector) { + console.error( + 'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]' + ) + process.exit(1) +} + +let reqId = 0 +const pending = new Map() +const streamListeners = new Map) => void>() +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `color-repro-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send(ws: WebSocket, method: string, params?: unknown): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + streamListeners.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, 10_000) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatError(response: RpcResponse): string { + return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) +} + +async function listHandles( + ws: WebSocket +): Promise> { + const response = await send(ws, 'terminal.list', { worktree: worktreeSelector }) + if (!response.ok) { + throw new Error(`terminal.list failed: ${formatError(response)}`) + } + return ( + (response.result?.terminals ?? []) as Array<{ handle: string; title?: string | null }> + ).map((terminal) => ({ + handle: terminal.handle, + title: terminal.title ?? null + })) +} + +async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise { + const terminals = await listHandles(ws) + const existing = terminals.find((terminal) => terminal.handle !== handleA) + if (existing) return existing.handle + + const created = await send(ws, 'terminal.create', { + worktree: worktreeSelector, + title: 'color-repro-switch-target' + }) + if (!created.ok) { + throw new Error(`terminal.create failed: ${formatError(created)}`) + } + const handle = (created.result?.terminal as { handle?: string } | undefined)?.handle + if (!handle) { + throw new Error(`terminal.create returned no handle: ${formatError(created)}`) + } + return handle +} + +async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise { + const id = nextId() + const snapshot = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + streamListeners.delete(id) + reject(new Error(`Timed out waiting for scrollback snapshot ${label}`)) + }, 10_000) + + pending.set(id, { + method: 'terminal.subscribe', + resolve: () => {}, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + streamListeners.set(id, (result) => { + if (result.type !== 'scrollback') return + clearTimeout(timeout) + pending.delete(id) + streamListeners.delete(id) + const serialized = typeof result.serialized === 'string' ? result.serialized : '' + const rawLines = result.lines + const lines = Array.isArray(rawLines) ? rawLines.join('\n') : String(rawLines ?? '') + resolve({ + label, + handle, + cols: typeof result.cols === 'number' ? result.cols : null, + rows: typeof result.rows === 'number' ? result.rows : null, + serialized, + lines + }) + }) + + sendRaw(ws, { + id, + deviceToken: token, + method: 'terminal.subscribe', + params: { terminal: handle } + }) + }) + + await send(ws, 'terminal.unsubscribe', { subscriptionId: handle }).catch(() => null) + return snapshot +} + +function countMatches(value: string, pattern: RegExp): number { + return value.match(pattern)?.length ?? 0 +} + +function summarize(snapshot: Snapshot): Record { + const data = snapshot.serialized + return { + label: snapshot.label, + handle: snapshot.handle, + cols: snapshot.cols, + rows: snapshot.rows, + serializedBytes: Buffer.byteLength(data), + sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')), + sgrColor: countMatches( + data, + new RegExp( + `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`, + 'g' + ) + ), + sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')), + altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no', + containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no', + containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no' + } +} + +function saveSnapshots(snapshots: Snapshot[]): string { + const dir = join(process.cwd(), 'terminal-color-repro') + mkdirSync(dir, { recursive: true }) + for (const snapshot of snapshots) { + const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-') + writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines) + writeFileSync( + join(dir, `${base}.escaped.txt`), + JSON.stringify(snapshot.serialized || snapshot.lines) + ) + } + writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2)) + return dir +} + +async function run(ws: WebSocket): Promise { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: toBase64(clientKeys.publicKey) + }) + ) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const terminals = await listHandles(ws) + if (terminals.length === 0 && !explicitHandleA) { + throw new Error('No terminals found. Open a Claude Code terminal first, then rerun.') + } + + const handleA = explicitHandleA ?? terminals[0]!.handle + const handleB = explicitHandleB ?? (await ensureSecondHandle(ws, handleA)) + + console.log(`A: ${handleA}`) + console.log(`B: ${handleB}`) + + const firstA = await captureSnapshot(ws, 'a-before-switch', handleA) + const firstB = await captureSnapshot(ws, 'b-during-switch', handleB) + const secondA = await captureSnapshot(ws, 'a-after-switch', handleA) + const snapshots = [firstA, firstB, secondA] + const dir = saveSnapshots(snapshots) + + console.table(snapshots.map(summarize)) + console.log(`saved: ${dir}`) + if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) { + console.log( + 'color SGR count changed between A snapshots: serialization/state changed on desktop' + ) + } else { + console.log('A snapshots have the same color SGR count: likely mobile replay/render path') + } +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const raw = data.toString() + if (raw.startsWith('{')) { + return + } + const plaintext = decrypt(raw) + if (!plaintext) return + + const response = JSON.parse(plaintext) as RpcResponse + const result = response.result + const streamListener = streamListeners.get(response.id) + if (streamListener && response.ok && (response.streaming || result?.type === 'scrollback')) { + streamListener(result ?? {}) + return + } + + const request = pending.get(response.id) + if (!request || request.method === 'terminal.subscribe') return + pending.delete(response.id) + request.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + process.exit(1) +}) diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts new file mode 100644 index 00000000000..bc1ab769564 --- /dev/null +++ b/mobile/scripts/repro-worktree-startup-stream.ts @@ -0,0 +1,367 @@ +/** + * Captures the mobile WebSocket stream during worktree startup. + * + * Usage: + * pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand] + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const USER_DATA = + process.env.ORCA_USER_DATA ?? `${process.env.HOME}/Library/Application Support/orca-dev` +const repoSelector = process.argv[2] +const worktreeName = process.argv[3] +const startupCommand = process.argv[4] || 'claude' +const ESC = String.fromCharCode(27) + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type TerminalInfo = { + handle: string + title: string | null +} + +type Capture = { + handle: string + title: string | null + scrollback: Record | null + chunks: string[] +} + +if (!repoSelector || !worktreeName) { + console.error( + 'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]' + ) + process.exit(1) +} + +function readJson(path: string): T { + if (!existsSync(path)) { + throw new Error(`Missing ${path}`) + } + return JSON.parse(readFileSync(path, 'utf8')) as T +} + +const devices = readJson>(join(USER_DATA, 'orca-devices.json')) +const token = devices[0]?.token +const keypair = readJson<{ publicKeyB64: string }>(join(USER_DATA, 'orca-e2ee-keypair.json')) + +if (!token || !keypair.publicKeyB64) { + throw new Error(`Missing mobile token or E2EE public key in ${USER_DATA}`) +} + +let reqId = 0 +const pending = new Map() +const streamListeners = new Map) => void>() +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(keypair.publicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `startup-repro-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send( + ws: WebSocket, + method: string, + params?: unknown, + timeoutMs = 30_000 +): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, timeoutMs) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatError(response: RpcResponse): string { + return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) +} + +function stripAnsi(value: string): string { + return ( + value + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, '\n') + ) +} + +function normalizePreview(value: string): string { + return stripAnsi(value) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8) + .join('\n') +} + +async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + const response = await send(ws, 'terminal.list', { worktree: worktreeId }) + if (!response.ok) { + throw new Error(`terminal.list failed: ${formatError(response)}`) + } + const terminals = (response.result?.terminals ?? []) as Array<{ + handle?: string + title?: string | null + }> + const handles = terminals + .filter((terminal): terminal is { handle: string; title?: string | null } => + Boolean(terminal.handle) + ) + .map((terminal) => ({ handle: terminal.handle, title: terminal.title ?? null })) + if (handles.length > 0) { + return handles + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error('Timed out waiting for startup terminals') +} + +async function subscribe(ws: WebSocket, capture: Capture): Promise { + const id = nextId() + streamListeners.set(id, (result) => { + if (result.type === 'scrollback') { + capture.scrollback = result + return + } + if (result.type === 'data' && typeof result.chunk === 'string') { + capture.chunks.push(result.chunk) + } + }) + sendRaw(ws, { + id, + deviceToken: token, + method: 'terminal.subscribe', + params: { terminal: capture.handle } + }) +} + +function summarize(capture: Capture): Record { + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + const live = capture.chunks.join('') + const serializedPreview = normalizePreview(serialized) + const livePreview = normalizePreview(live) + return { + handle: capture.handle, + title: capture.title, + cols: capture.scrollback?.cols ?? null, + rows: capture.scrollback?.rows ?? null, + serializedBytes: Buffer.byteLength(serialized), + liveBytes: Buffer.byteLength(live), + liveChunks: capture.chunks.length, + serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + livePreviewContainedInSerialized: + livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''), + serializedPreview, + livePreview + } +} + +function save(captures: Capture[], worktreeName: string): string { + const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName) + mkdirSync(dir, { recursive: true }) + for (const capture of captures) { + const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-') + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + writeFileSync(join(dir, `${base}.serialized.ansi`), serialized) + writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join('')) + writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2)) + } + writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2)) + return dir +} + +async function run(ws: WebSocket): Promise { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: toBase64(clientKeys.publicKey) + }) + ) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const created = await send( + ws, + 'worktree.create', + { repo: repoSelector, name: worktreeName, startupCommand }, + 120_000 + ) + if (!created.ok) { + throw new Error(`worktree.create failed: ${formatError(created)}`) + } + const worktree = created.result?.worktree as { id?: string; path?: string } | undefined + if (!worktree?.id) { + throw new Error(`worktree.create returned no worktree id: ${formatError(created)}`) + } + + console.log(`worktree: ${worktree.id}`) + const terminals = await waitForTerminals(ws, worktree.id) + console.log(`terminals: ${terminals.map((terminal) => terminal.handle).join(', ')}`) + const captures = terminals.map((terminal) => ({ + handle: terminal.handle, + title: terminal.title, + scrollback: null, + chunks: [] + })) + for (const capture of captures) { + await subscribe(ws, capture) + } + + await new Promise((resolve) => setTimeout(resolve, 15_000)) + for (const capture of captures) { + await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null) + } + const dir = save(captures, worktreeName) + console.table(captures.map(summarize)) + console.log(`saved: ${dir}`) +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const raw = data.toString() + if (raw.startsWith('{')) { + return + } + const plaintext = decrypt(raw) + if (!plaintext) return + + const response = JSON.parse(plaintext) as RpcResponse + const result = response.result + const streamListener = streamListeners.get(response.id) + if (streamListener && response.ok && (response.streaming || result?.type)) { + streamListener(result ?? {}) + return + } + + const request = pending.get(response.id) + if (!request || request.method === 'terminal.subscribe') return + pending.delete(response.id) + request.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + process.exit(1) +}) diff --git a/mobile/scripts/test-subscribe.ts b/mobile/scripts/test-subscribe.ts new file mode 100644 index 00000000000..6fa8ffe4b42 --- /dev/null +++ b/mobile/scripts/test-subscribe.ts @@ -0,0 +1,324 @@ +/** + * Lightweight terminal streaming repro for the mobile WebSocket RPC. + * + * Usage: + * pnpm exec tsx scripts/test-subscribe.ts [worktreeSelector] + * + * Example: + * pnpm exec tsx scripts/test-subscribe.ts "$TOKEN" "$SERVER_PUBLIC_KEY" \ + * "id:repo-id::/path/to/worktree" + */ +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const token = process.argv[2] +const serverPublicKeyB64 = process.argv[3] +const worktreeSelector = process.argv[4] +const marker = `MOBILE_STREAM_${Date.now()}` + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } + _meta?: { runtimeId: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +if (!token || !serverPublicKeyB64) { + console.error( + 'Usage: pnpm exec tsx scripts/test-subscribe.ts [worktreeSelector]' + ) + process.exit(1) +} + +let reqId = 0 +const pending = new Map() +let streamSawMarker = false +let readSawMarker = false +let activeHandle: string | null = null +let runtimeId = '' +let scrollbackCols: number | null = null +let scrollbackRows: number | null = null +let serializedLength = 0 +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `test-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send(ws: WebSocket, method: string, params?: unknown): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, 10_000) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatResponse(response: RpcResponse): string { + return JSON.stringify(response.result ?? response.error ?? response).slice(0, 1000) +} + +async function chooseWorktree(ws: WebSocket): Promise { + if (worktreeSelector) return worktreeSelector + + const response = await send(ws, 'worktree.ps') + if (!response.ok) { + throw new Error(`worktree.ps failed: ${formatResponse(response)}`) + } + + const worktrees = (response.result?.worktrees ?? []) as Array<{ + worktreeId: string + liveTerminalCount: number + branch: string + path: string + }> + const selected = worktrees.find((w) => w.liveTerminalCount > 0) ?? worktrees[0] + if (!selected) { + throw new Error('No worktrees returned by worktree.ps') + } + + console.log(`worktree: ${selected.branch || '(no branch)'} ${selected.path}`) + return `id:${selected.worktreeId}` +} + +async function chooseTerminal(ws: WebSocket, worktree: string): Promise { + const list = await send(ws, 'terminal.list', { worktree }) + if (!list.ok) { + throw new Error(`terminal.list failed: ${formatResponse(list)}`) + } + + const terminals = (list.result?.terminals ?? []) as Array<{ + handle: string + title: string + preview: string + lastOutputAt: number | null + }> + + if (terminals.length > 0) { + const selected = terminals[0]! + console.log(`terminal: ${selected.title || selected.handle} ${selected.handle}`) + return selected.handle + } + + const created = await send(ws, 'terminal.create', { + worktree, + title: 'mobile-stream-repro' + }) + if (!created.ok) { + throw new Error(`terminal.create failed: ${formatResponse(created)}`) + } + + const terminal = created.result?.terminal as { handle?: string; title?: string } | undefined + if (!terminal?.handle) { + throw new Error(`terminal.create returned no handle: ${formatResponse(created)}`) + } + console.log(`terminal: ${terminal.title || terminal.handle} ${terminal.handle}`) + return terminal.handle +} + +async function run(ws: WebSocket): Promise { + console.log(`connected: ${WS_URL}`) + ws.send(JSON.stringify({ type: 'e2ee_hello', publicKeyB64: toBase64(clientKeys.publicKey) })) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + sendRaw(ws, { type: 'e2ee_auth', deviceToken: token }) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for e2ee_authenticated')), + 5000 + ) + ws.once('message', (data) => { + clearTimeout(timeout) + const plaintext = decrypt(data.toString()) + const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null + if (msg?.type !== 'e2ee_authenticated') { + reject(new Error(`Unexpected auth response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const worktree = await chooseWorktree(ws) + const handle = await chooseTerminal(ws, worktree) + activeHandle = handle + + void send(ws, 'terminal.subscribe', { terminal: handle }).catch((error) => { + console.error(`subscribe failed: ${error.message}`) + }) + + await new Promise((resolve) => setTimeout(resolve, 500)) + + const sendResponse = await send(ws, 'terminal.send', { + terminal: handle, + text: `echo ${marker}`, + enter: true + }) + if (!sendResponse.ok) { + throw new Error(`terminal.send failed: ${formatResponse(sendResponse)}`) + } + console.log(`sent marker: ${marker}`) + + const deadline = Date.now() + 5_000 + while (Date.now() < deadline && !streamSawMarker && !readSawMarker) { + await new Promise((resolve) => setTimeout(resolve, 500)) + const read = await send(ws, 'terminal.read', { terminal: handle }) + if (!read.ok) { + throw new Error(`terminal.read failed: ${formatResponse(read)}`) + } + const terminal = read.result?.terminal as { tail?: string[]; preview?: string } | undefined + const text = [...(terminal?.tail ?? []), terminal?.preview ?? ''].join('\n') + if (text.includes(marker)) { + readSawMarker = true + } + } + + console.log(`runtime: ${runtimeId || '(unknown)'}`) + console.log(`scrollbackCols: ${scrollbackCols ?? '(none)'}`) + console.log(`scrollbackRows: ${scrollbackRows ?? '(none)'}`) + console.log(`serializedLength: ${serializedLength}`) + console.log(`streamSawMarker: ${streamSawMarker}`) + console.log(`readSawMarker: ${readSawMarker}`) + + if (!streamSawMarker && !readSawMarker) { + throw new Error('terminal output did not reach stream or terminal.read') + } +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const plaintext = decrypt(data.toString()) + if (!plaintext) return + const response = JSON.parse(plaintext) as RpcResponse + if (response._meta?.runtimeId) { + runtimeId = response._meta.runtimeId + } + + const result = response.result + if (response.streaming && result?.type === 'data') { + const chunk = typeof result.chunk === 'string' ? result.chunk : '' + if (chunk.includes(marker)) { + streamSawMarker = true + } + } + + if (response.streaming && result?.type === 'scrollback') { + const lines = Array.isArray(result.lines) ? result.lines.join('\n') : String(result.lines ?? '') + scrollbackCols = typeof result.cols === 'number' ? result.cols : null + scrollbackRows = typeof result.rows === 'number' ? result.rows : null + serializedLength = typeof result.serialized === 'string' ? result.serialized.length : 0 + if (lines.includes(marker)) { + streamSawMarker = true + } + if (typeof result.serialized === 'string' && result.serialized.includes(marker)) { + streamSawMarker = true + } + } + + const pendingRequest = pending.get(response.id) + if (!pendingRequest) { + return + } + + if (response.streaming && pendingRequest.method === 'terminal.subscribe') { + return + } + + pending.delete(response.id) + pendingRequest.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + if (activeHandle) { + console.error(`active terminal: ${activeHandle}`) + } + process.exit(1) +}) diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts new file mode 100644 index 00000000000..57256be8ea0 --- /dev/null +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -0,0 +1,66 @@ +// Why: persist the data needed to render the home page so cold-start / +// resume-from-background paints instantly with the last known good +// values, then updates in place when fresh RPC data arrives. Without +// this, Resume and Account-usage cards flash empty for ~1s while the +// WebSocket reconnects and the first responses come back. +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { AccountsSnapshot } from '../components/AccountUsage' + +const STORAGE_KEY = 'orca:home-snapshot:v1' + +type WorktreeSummary = { + worktreeId: string + repo: string + branch: string + displayName: string + liveTerminalCount: number + status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' +} + +type HostWorktreeInfo = { + hostId: string + totalWorktrees: number + activeCount: number + lastActiveWorktree: WorktreeSummary | null +} + +export type HomeSnapshot = { + worktreeInfo: Record + accountsByHost: Record + savedAt: number +} + +let memoryCache: HomeSnapshot | null = null +let writeTimer: ReturnType | null = null + +export async function loadHomeSnapshot(): Promise { + if (memoryCache) return memoryCache + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as HomeSnapshot + if ( + typeof parsed !== 'object' || + parsed === null || + typeof parsed.worktreeInfo !== 'object' || + typeof parsed.accountsByHost !== 'object' + ) { + return null + } + memoryCache = parsed + return parsed + } catch { + return null + } +} + +// Why: throttle writes so a flurry of streamed account-snapshot updates +// (one per provider fetch finishing) doesn't hammer AsyncStorage. +export function saveHomeSnapshot(snapshot: HomeSnapshot): void { + memoryCache = snapshot + if (writeTimer) clearTimeout(writeTimer) + writeTimer = setTimeout(() => { + writeTimer = null + void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {}) + }, 250) +} diff --git a/mobile/src/cache/worktree-cache.ts b/mobile/src/cache/worktree-cache.ts new file mode 100644 index 00000000000..9654dd357ad --- /dev/null +++ b/mobile/src/cache/worktree-cache.ts @@ -0,0 +1,35 @@ +// Why: module-level cache lets the home screen pre-populate worktree data +// so the host detail page can render instantly on navigation instead of +// waiting for a fresh RPC connection + fetch cycle. + +type CachedWorktrees = { + worktrees: unknown[] + at: number +} + +const cache = new Map() + +const MAX_AGE_MS = 30_000 +const MAX_ENTRIES = 20 + +export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void { + // Why: Map.set on an existing key does not move it to the end of iteration + // order. Delete first so the re-inserted key becomes the newest entry, + // giving us true LRU eviction when the cap is hit. + cache.delete(hostId) + cache.set(hostId, { worktrees, at: Date.now() }) + if (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest) cache.delete(oldest) + } +} + +export function getCachedWorktrees(hostId: string): unknown[] | null { + const entry = cache.get(hostId) + if (!entry) return null + if (Date.now() - entry.at > MAX_AGE_MS) { + cache.delete(hostId) + return null + } + return entry.worktrees +} diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx new file mode 100644 index 00000000000..0ff706638b9 --- /dev/null +++ b/mobile/src/components/AccountUsage.tsx @@ -0,0 +1,155 @@ +import { View, Text, StyleSheet, ActivityIndicator } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +// Why: keep these shapes in lockstep with src/shared/types.ts and +// src/shared/rate-limit-types.ts. We don't import from desktop here because +// the mobile bundle must not pull in Electron-coupled type files. +export type RateLimitWindow = { + usedPercent: number + windowMinutes: number + resetsAt: number | null + resetDescription: string | null +} + +export type ProviderRateLimits = { + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' + session: RateLimitWindow | null + weekly: RateLimitWindow | null + monthly?: RateLimitWindow | null + updatedAt: number + error: string | null + status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' +} + +export type InactiveAccountUsage = { + accountId: string + claude: ProviderRateLimits | null + updatedAt: number + isFetching: boolean +} + +export type ClaudeAccountSummary = { + id: string + email: string + organizationName?: string | null +} + +export type CodexAccountSummary = { + id: string + email: string + workspaceLabel?: string | null +} + +export type AccountsSnapshot = { + claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null } + codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null } + rateLimits: { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + inactiveClaudeAccounts: InactiveAccountUsage[] + inactiveCodexAccounts: InactiveAccountUsage[] + } +} + +export type ProviderKey = 'claude' | 'codex' + +export function getActiveProviderRateLimits( + snapshot: AccountsSnapshot, + provider: ProviderKey +): ProviderRateLimits | null { + return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex +} + +export function getInactiveProviderUsage( + snapshot: AccountsSnapshot, + provider: ProviderKey, + accountId: string +): InactiveAccountUsage | null { + const list = + provider === 'claude' + ? snapshot.rateLimits.inactiveClaudeAccounts + : snapshot.rateLimits.inactiveCodexAccounts + return list.find((u) => u.accountId === accountId) ?? null +} + +// Why: matches desktop StatusBar convention — bars show percent remaining +// (so a fresh account renders full, a depleted one renders empty), not +// percent used. Color thresholds invert accordingly. +export function UsageBar({ + label, + usedPercent, + unavailable, + loading +}: { + label: string + usedPercent: number | null + unavailable: boolean + loading?: boolean +}) { + const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent)) + const barColor = + remaining == null + ? colors.textMuted + : remaining <= 10 + ? colors.statusRed + : remaining <= 30 + ? colors.statusAmber + : colors.statusGreen + return ( + + {label} + + + + {loading ? ( + + ) : ( + + {unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`} + + )} + + ) +} + +const styles = StyleSheet.create({ + usageBar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + flex: 1 + }, + usageLabel: { + fontSize: typography.metaSize, + color: colors.textMuted, + width: 22 + }, + usageTrack: { + flex: 1, + height: 6, + borderRadius: 3, + backgroundColor: colors.bgRaised, + overflow: 'hidden' + }, + usageFill: { + height: '100%', + borderRadius: 3 + }, + usageValue: { + fontSize: typography.metaSize, + color: colors.textSecondary, + width: 36, + textAlign: 'right' + }, + usageSpinner: { + width: 36 + } +}) diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx new file mode 100644 index 00000000000..a68b08ea637 --- /dev/null +++ b/mobile/src/components/ActionSheetModal.tsx @@ -0,0 +1,133 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type ActionSheetAction = { + label: string + icon?: LucideIcon + destructive?: boolean + skipAutoClose?: boolean + onPress: () => void +} + +type Props = { + visible: boolean + title?: string + message?: string + actions: ActionSheetAction[] + onClose: () => void +} + +function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon): LucideIcon { + if (icon) return icon + if (destructive || /delete|remove/i.test(label)) return Trash2 + return Edit3 +} + +type ContentProps = { + title?: string + message?: string + actions: ActionSheetAction[] + onClose?: () => void +} + +export function ActionSheetContent({ title, message, actions, onClose }: ContentProps) { + return ( + <> + {(title || message) && ( + + {title ? ( + + {title} + + ) : null} + {message ? {message} : null} + + )} + + + {actions.map((action, i) => { + const Icon = iconForAction(action.label, action.destructive, action.icon) + return ( + + {i > 0 && } + [styles.action, pressed && styles.actionPressed]} + onPress={() => { + action.onPress() + if (!action.skipAutoClose && onClose) { + onClose() + } + }} + > + + + {action.label} + + + + ) + })} + + + ) +} + +export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) { + return ( + + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + message: { + fontSize: 12, + color: colors.textMuted, + marginTop: 2 + }, + actionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + action: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + actionPressed: { + backgroundColor: colors.bgRaised + }, + actionText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + actionTextDestructive: { + color: colors.statusRed + } +}) diff --git a/mobile/src/components/AgentIcons.tsx b/mobile/src/components/AgentIcons.tsx new file mode 100644 index 00000000000..714fe76e0f5 --- /dev/null +++ b/mobile/src/components/AgentIcons.tsx @@ -0,0 +1,30 @@ +import Svg, { Path } from 'react-native-svg' +import { colors } from '../theme/mobile-theme' + +// Why: SVG paths sourced from the desktop codebase +// (src/renderer/src/components/status-bar/icons.tsx) so mobile and desktop +// stay visually identical for Claude/Codex branding. + +export function ClaudeIcon({ size = 16 }: { size?: number }) { + return ( + + + + ) +} + +export function OpenAIIcon({ size = 16, color }: { size?: number; color?: string }) { + return ( + + + + ) +} diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx new file mode 100644 index 00000000000..673bbff049e --- /dev/null +++ b/mobile/src/components/AgentSpinner.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react' +import { Animated, Easing, StyleSheet, View } from 'react-native' + +type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' + +// Why: colors and sizing are 1:1 with the desktop StatusIndicator +// (src/renderer/src/components/sidebar/StatusIndicator.tsx) so the mobile +// worktree list reads identically to the sidebar — same yellow spinner for +// 'working', same emerald dot for 'active'/'done', same neutral-500 @ 40% +// for 'inactive', same red for 'permission'. Diverging palettes here lose +// the design intent ('moving' vs 'alive' vs 'completed') the desktop encodes. +const STATUS_COLORS: Record = { + working: '#eab308', + active: '#10b981', + done: '#10b981', + permission: '#ef4444', + inactive: 'rgba(115,115,115,0.4)' +} + +export function AgentSpinner({ status }: { status: WorktreeStatus }) { + const spinValue = useRef(new Animated.Value(0)).current + + useEffect(() => { + if (status === 'working') { + const animation = Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true + }) + ) + animation.start() + return () => animation.stop() + } + spinValue.setValue(0) + }, [status, spinValue]) + + const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive + + if (status === 'working') { + const rotate = spinValue.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'] + }) + return ( + + + + ) + } + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + // Why: 12x12 wrapper centered around an 8x8 inner glyph mirrors the + // desktop's `inline-flex h-3 w-3 ... items-center justify-center` shell + // around `size-2` indicator — keeps row height/baseline alignment stable + // across status transitions. + wrapper: { + width: 12, + height: 12, + alignItems: 'center', + justifyContent: 'center' + }, + dot: { + width: 8, + height: 8, + borderRadius: 4 + }, + spinner: { + width: 8, + height: 8, + borderRadius: 4, + borderWidth: 2, + borderTopColor: 'transparent' + } +}) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx new file mode 100644 index 00000000000..2698353fb92 --- /dev/null +++ b/mobile/src/components/BottomDrawer.tsx @@ -0,0 +1,252 @@ +import { type ReactNode, useCallback, useEffect, useState } from 'react' +import { + View, + Pressable, + StyleSheet, + Platform, + useWindowDimensions, + ScrollView, + Keyboard, + BackHandler +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + withTiming, + runOnJS, + interpolate, + Extrapolation +} from 'react-native-reanimated' +import { colors, spacing } from '../theme/mobile-theme' + +const DISMISS_THRESHOLD = 80 +const SPRING_CONFIG = { damping: 28, stiffness: 400 } +// Why: negative translateY (pulling up) is damped with a rubber-band factor +// so the drawer resists upward dragging — a subtle polish touch that signals +// the drawer cannot expand further. +const RUBBER_BAND_FACTOR = 0.25 +const SHOW_DURATION = 180 +const HIDE_DURATION = 150 + +type Props = { + visible: boolean + onClose: () => void + children: ReactNode +} + +export function BottomDrawer({ visible, onClose, children }: Props) { + const [mounted, setMounted] = useState(visible) + const translateY = useSharedValue(0) + const progress = useSharedValue(0) + const keyboardOffset = useSharedValue(0) + const { height: screenHeight } = useWindowDimensions() + const insets = useSafeAreaInsets() + + useEffect(() => { + if (visible) { + setMounted(true) + } + }, [visible]) + + useEffect(() => { + if (!mounted) return + + if (visible) { + translateY.value = 0 + progress.value = withTiming(1, { duration: SHOW_DURATION }) + } else { + Keyboard.dismiss() + progress.value = withTiming(0, { duration: HIDE_DURATION }, (finished) => { + if (finished) { + runOnJS(setMounted)(false) + } + }) + } + }, [mounted, visible]) + + // Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable + // inside Modal (iOS ignores KAV; Android needs adjustNothing for + // useAnimatedKeyboard). Keyboard event listeners work on both platforms + // and give us the exact height to shift the drawer by. + useEffect(() => { + if (!visible) return + + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' + + const onShow = Keyboard.addListener(showEvent, (e) => { + const height = e.endCoordinates.height - insets.bottom + keyboardOffset.value = withTiming(Math.max(height, 0), { duration: e.duration || 250 }) + }) + const onHide = Keyboard.addListener(hideEvent, (e) => { + keyboardOffset.value = withTiming(0, { duration: e.duration || 250 }) + }) + + return () => { + onShow.remove() + onHide.remove() + keyboardOffset.value = 0 + } + }, [visible, insets.bottom]) + + useEffect(() => { + if (!visible) return + + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + onClose() + return true + }) + return () => sub.remove() + }, [visible, onClose]) + + const dismiss = useCallback(() => { + onClose() + }, [onClose]) + + const panGesture = Gesture.Pan() + .onUpdate((e) => { + if (e.translationY > 0) { + translateY.value = e.translationY + } else { + translateY.value = e.translationY * RUBBER_BAND_FACTOR + } + }) + .onEnd((e) => { + if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) { + const velocity = Math.max(e.velocityY, 800) + const remaining = screenHeight - e.translationY + const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300) + translateY.value = withTiming(screenHeight, { duration }) + progress.value = withTiming(0, { duration }, () => { + runOnJS(dismiss)() + }) + } else { + translateY.value = withSpring(0, SPRING_CONFIG) + } + }) + + const drawerStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: + interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) + + translateY.value - + keyboardOffset.value + } + ] + })) + + const backdropStyle = useAnimatedStyle(() => { + const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP) + return { opacity: progress.value * dragFade } + }) + + const pointerStyle = useAnimatedStyle( + () => + ({ + pointerEvents: progress.value > 0 ? 'auto' : 'none' + }) as { pointerEvents: 'auto' | 'none' } + ) + + // Why: hidden drawers can contain auto-focused inputs; keeping them mounted + // lets Android open the keyboard even when the drawer is offscreen. + if (!mounted) return null + + return ( + + + + + + + + + + + + + + + {children} + + + + + + + ) +} + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 1000 + }, + root: { + flex: 1 + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)' + }, + anchor: { + flex: 1, + justifyContent: 'flex-end' + }, + drawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: spacing.md, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 + }, + android: { elevation: 8 } + }) + }, + handle: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.textMuted, + opacity: 0.4 + }, + handleHitArea: { + alignItems: 'center', + paddingTop: spacing.sm, + paddingBottom: spacing.md + }, + bottomExtension: { + position: 'absolute', + bottom: -500, + left: 0, + right: 0, + height: 500, + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/src/components/ConfirmModal.tsx b/mobile/src/components/ConfirmModal.tsx new file mode 100644 index 00000000000..a393593b5cf --- /dev/null +++ b/mobile/src/components/ConfirmModal.tsx @@ -0,0 +1,111 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +type Props = { + visible: boolean + title: string + message?: string + confirmLabel?: string + cancelLabel?: string + destructive?: boolean + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmModal({ + visible, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + destructive = false, + onConfirm, + onCancel +}: Props) { + return ( + + + {title} + {message ? {message} : null} + + + [styles.button, styles.cancelButton, pressed && styles.pressed]} + onPress={onCancel} + > + {cancelLabel} + + [ + styles.button, + destructive ? styles.destructiveButton : styles.confirmButton, + pressed && styles.pressed + ]} + onPress={() => { + onConfirm() + onCancel() + }} + > + + {confirmLabel} + + + + + ) +} + +const styles = StyleSheet.create({ + content: { + paddingBottom: spacing.lg + }, + title: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary + }, + message: { + fontSize: typography.bodySize, + color: colors.textSecondary, + marginTop: spacing.xs, + lineHeight: 20 + }, + buttons: { + flexDirection: 'row', + gap: spacing.sm + }, + button: { + flex: 1, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + cancelButton: { + backgroundColor: colors.bgPanel + }, + confirmButton: { + backgroundColor: colors.textPrimary + }, + destructiveButton: { + backgroundColor: colors.statusRed + }, + pressed: { + opacity: 0.7 + }, + cancelText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textSecondary + }, + confirmText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.bgBase + }, + destructiveText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: '#fff' + } +}) diff --git a/mobile/src/components/ConnectionLog.tsx b/mobile/src/components/ConnectionLog.tsx new file mode 100644 index 00000000000..3722ba0db25 --- /dev/null +++ b/mobile/src/components/ConnectionLog.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef } from 'react' +import { ScrollView, StyleSheet, Text, View } from 'react-native' +import type { ConnectionLogEntry } from '../transport/types' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +type Props = { + entries: ConnectionLogEntry[] + // Tag printed before the first entry so it's clear what's being logged + // (e.g. 'Pairing' vs 'Reconnect'). + title?: string +} + +const LEVEL_COLOR: Record = { + info: colors.textSecondary, + success: colors.statusGreen, + warn: colors.statusAmber, + error: colors.statusRed +} + +const LEVEL_GLYPH: Record = { + info: '•', + success: '✓', + warn: '!', + error: '✕' +} + +function formatTime(ts: number, baseTs: number): string { + // Why: show elapsed seconds since the first entry — absolute wall-clock + // time isn't actionable when debugging "why is connecting stuck". + const elapsed = Math.max(0, ts - baseTs) / 1000 + if (elapsed < 10) return `+${elapsed.toFixed(2)}s` + if (elapsed < 100) return `+${elapsed.toFixed(1)}s` + return `+${Math.round(elapsed)}s` +} + +export function ConnectionLog({ entries, title }: Props) { + const scrollRef = useRef(null) + + // Why: keep the latest entry visible while logs grow during a slow + // connect. Skip on empty so the title row doesn't jump. + useEffect(() => { + if (entries.length === 0) return + const id = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 16) + return () => clearTimeout(id) + }, [entries.length]) + + if (entries.length === 0) return null + const baseTs = entries[0]!.ts + + return ( + + {title && {title}} + + {entries.map((entry) => ( + + {formatTime(entry.ts, baseTs)} + + {LEVEL_GLYPH[entry.level]} + + + + {entry.message} + + {entry.detail && ( + + {entry.detail} + + )} + + + ))} + + + ) +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + maxHeight: 240, + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md + }, + title: { + fontSize: typography.metaSize, + fontFamily: typography.monoFamily, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 1, + marginBottom: spacing.xs + }, + scroll: { + maxHeight: 200 + }, + scrollContent: { + gap: 6 + }, + row: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + timestamp: { + fontFamily: typography.monoFamily, + fontSize: typography.metaSize, + color: colors.textMuted, + width: 52, + paddingTop: 1 + }, + glyph: { + fontFamily: typography.monoFamily, + fontSize: typography.metaSize, + width: 12, + textAlign: 'center', + paddingTop: 1 + }, + rowText: { + flex: 1 + }, + message: { + fontFamily: typography.monoFamily, + fontSize: typography.metaSize, + lineHeight: 16 + }, + detail: { + fontFamily: typography.monoFamily, + fontSize: 11, + color: colors.textMuted, + lineHeight: 14, + marginTop: 1 + } +}) diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx new file mode 100644 index 00000000000..ba264548dde --- /dev/null +++ b/mobile/src/components/CustomKeyModal.tsx @@ -0,0 +1,338 @@ +import { useState, useEffect, useCallback } from 'react' +import { View, Text, Pressable, TextInput, StyleSheet, ScrollView, Switch } from 'react-native' +import { ChevronLeft } from 'lucide-react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +const STORAGE_KEY = 'orca:custom-accessory-keys' + +export type CustomKey = { + id: string + label: string + bytes: string + enter: boolean +} + +type Step = 'choose-type' | 'pick-ctrl' | 'pick-alt' | 'text-macro' + +const ALPHA_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + +function ctrlBytes(letter: string): string { + return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64) +} + +function altBytes(letter: string): string { + return `\x1b${letter.toLowerCase()}` +} + +type Props = { + visible: boolean + onClose: () => void + onKeysChanged: (keys: CustomKey[]) => void +} + +export async function loadCustomKeys(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as CustomKey[]) : [] + } catch { + return [] + } +} + +async function saveCustomKeys(keys: CustomKey[]): Promise { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys)) +} + +export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { + const [step, setStep] = useState('choose-type') + const [macroLabel, setMacroLabel] = useState('') + const [macroText, setMacroText] = useState('') + const [macroEnter, setMacroEnter] = useState(true) + + useEffect(() => { + if (visible) { + setStep('choose-type') + setMacroLabel('') + setMacroText('') + setMacroEnter(true) + } + }, [visible]) + + const addKey = useCallback( + async (key: Omit) => { + const existing = await loadCustomKeys() + const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` } + const updated = [...existing, newKey] + await saveCustomKeys(updated) + onKeysChanged(updated) + onClose() + }, + [onClose, onKeysChanged] + ) + + const handleCtrlKey = useCallback( + (letter: string) => { + void addKey({ label: `Ctrl+${letter}`, bytes: ctrlBytes(letter), enter: false }) + }, + [addKey] + ) + + const handleAltKey = useCallback( + (letter: string) => { + void addKey({ label: `Alt+${letter}`, bytes: altBytes(letter), enter: false }) + }, + [addKey] + ) + + const handleMacroSave = useCallback(() => { + const label = macroLabel.trim() || macroText.trim().slice(0, 12) + const text = macroText + if (!label || !text) return + const bytes = macroEnter ? `${text}\r` : text + void addKey({ label, bytes, enter: false }) + }, [addKey, macroLabel, macroText, macroEnter]) + + const showBack = step !== 'choose-type' + + return ( + + + {showBack ? ( + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => setStep('choose-type')} + accessibilityLabel="Back" + > + + + ) : ( + + )} + + {step === 'choose-type' && 'Add Shortcut'} + {step === 'pick-ctrl' && 'Ctrl + Key'} + {step === 'pick-alt' && 'Alt + Key'} + {step === 'text-macro' && 'Text Macro'} + + + + + {step === 'choose-type' && ( + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('pick-ctrl')} + > + Ctrl + Key + Control character shortcuts + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('pick-alt')} + > + Alt + Key + Alt/Option key combos + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('text-macro')} + > + Text Macro + Send custom text command + + + )} + + {(step === 'pick-ctrl' || step === 'pick-alt') && ( + + + {ALPHA_KEYS.map((letter) => ( + [styles.keyCell, pressed && styles.keyCellPressed]} + onPress={() => + step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter) + } + > + {letter} + + ))} + + + )} + + {step === 'text-macro' && ( + + + Label + + Command + + + Press Enter + + + + Add Shortcut + + + + )} + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: spacing.sm + }, + backButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center' + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + backSpacer: { + width: 30 + }, + title: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + textAlign: 'center' + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary, + marginBottom: 1 + }, + rowHint: { + fontSize: 12, + color: colors.textMuted + }, + keyGridScroll: { + maxHeight: 240 + }, + keyGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + justifyContent: 'center', + padding: spacing.md + }, + keyCell: { + width: 42, + height: 38, + borderRadius: radii.button, + backgroundColor: colors.bgBase, + alignItems: 'center', + justifyContent: 'center' + }, + keyCellPressed: { + backgroundColor: colors.bgRaised + }, + keyCellText: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + fontFamily: typography.monoFamily + }, + macroForm: { + padding: spacing.md, + gap: spacing.sm + }, + fieldLabel: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary + }, + fieldInput: { + backgroundColor: colors.bgBase, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 14, + fontFamily: typography.monoFamily, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.xs + }, + switchLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + saveButton: { + backgroundColor: colors.textPrimary, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + saveButtonDisabled: { + opacity: 0.5 + }, + saveButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx new file mode 100644 index 00000000000..a7e7d2579b3 --- /dev/null +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -0,0 +1,784 @@ +import { useState, useEffect } from 'react' +import { + View, + Text, + TextInput, + Pressable, + Switch, + StyleSheet, + Platform, + ActivityIndicator, + Image +} from 'react-native' +import { ChevronDown, ChevronUp, Check, Terminal } from 'lucide-react-native' +import Svg, { Path, G } from 'react-native-svg' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' +import { ClaudeIcon, OpenAIIcon } from './AgentIcons' +import { getSuggestedCreatureName } from './worktree-name-suggestion' + +type Repo = { + id: string + displayName: string + path: string +} + +type AgentOption = { + id: string + label: string + faviconDomain?: string +} + +// Why: matches the AGENT_CATALOG ordering and faviconDomain values from +// src/renderer/src/lib/agent-catalog.tsx so mobile uses the same icon sources. +const AGENT_OPTIONS: AgentOption[] = [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' }, + { id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' }, + { id: 'pi', label: 'Pi' }, + { id: 'gemini', label: 'Gemini', faviconDomain: 'gemini.google.com' }, + { id: 'aider', label: 'Aider' }, + { id: 'goose', label: 'Goose', faviconDomain: 'goose-docs.ai' }, + { id: 'amp', label: 'Amp', faviconDomain: 'ampcode.com' }, + { id: 'kilo', label: 'Kilocode', faviconDomain: 'kilo.ai' }, + { id: 'kiro', label: 'Kiro', faviconDomain: 'kiro.dev' }, + { id: 'crush', label: 'Charm', faviconDomain: 'charm.sh' }, + { id: 'aug', label: 'Auggie', faviconDomain: 'augmentcode.com' }, + { id: 'cline', label: 'Cline', faviconDomain: 'cline.bot' }, + { id: 'codebuff', label: 'Codebuff', faviconDomain: 'codebuff.com' }, + { id: 'continue', label: 'Continue', faviconDomain: 'continue.dev' }, + { id: 'cursor', label: 'Cursor', faviconDomain: 'cursor.com' }, + { id: 'droid', label: 'Droid', faviconDomain: 'factory.ai' }, + { id: 'kimi', label: 'Kimi', faviconDomain: 'moonshot.cn' }, + { id: 'mistral-vibe', label: 'Mistral Vibe', faviconDomain: 'mistral.ai' }, + { id: 'qwen-code', label: 'Qwen Code', faviconDomain: 'qwenlm.github.io' }, + { id: 'rovo', label: 'Rovo Dev', faviconDomain: 'atlassian.com' }, + { id: 'hermes', label: 'Hermes', faviconDomain: 'nousresearch.com' } +] + +const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' } +const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL] + +// Why: mirrors launchCmd from src/shared/tui-agent-config.ts so terminal.create +// gets the correct binary name for each agent. +const AGENT_COMMANDS: Record = { + claude: 'claude', + codex: 'codex', + copilot: 'copilot', + opencode: 'opencode', + pi: 'pi', + gemini: 'gemini', + aider: 'aider', + goose: 'goose', + amp: 'amp', + kilo: 'kilo', + kiro: 'kiro', + crush: 'crush', + aug: 'auggie', + cline: 'cline', + codebuff: 'codebuff', + continue: 'continue', + cursor: 'cursor-agent', + droid: 'droid', + kimi: 'kimi', + 'mistral-vibe': 'mistral-vibe', + 'qwen-code': 'qwen-code', + rovo: 'rovo', + hermes: 'hermes' +} + +// ── Agent icons ───────────────────────────────────────────────────── +// SVG paths sourced from the desktop codebase: +// Claude & OpenAI: shared in ./AgentIcons.tsx +// Pi & Aider: src/renderer/src/lib/agent-catalog.tsx +// Agents with a faviconDomain use Google's favicon service (same as desktop). + +function PiIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ) +} + +function AiderIcon({ size = 16 }: { size?: number }) { + return ( + + + + + + + ) +} + +function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) { + return ( + + ) +} + +function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number }) { + return ( + + + {letter} + + + ) +} + +function AgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) { + if (agentId === 'claude') return + if (agentId === 'codex') return + if (agentId === 'pi') return + if (agentId === 'aider') return + if (agentId === '__blank__') return + + const agent = AGENT_OPTIONS.find((a) => a.id === agentId) + if (agent?.faviconDomain) { + return + } + const label = agent?.label ?? agentId + return +} + +// ── Picker sub-modal ──────────────────────────────────────────────── +// Why: inline dropdowns with position:absolute + ScrollView have persistent +// touch-conflict issues in React Native. A separate modal for the picker +// list is the standard mobile pattern — it scrolls reliably and feels native. + +function PickerListModal({ + visible, + title, + items, + selectedId, + onSelect, + onClose, + renderIcon +}: { + visible: boolean + title: string + items: T[] + selectedId: string + onSelect: (item: T) => void + onClose: () => void + renderIcon?: (item: T) => React.ReactNode +}) { + return ( + + + {title} + + + {items.map((item, index) => { + const selected = item.id === selectedId + return ( + + {index > 0 && } + [styles.pickerItem, pressed && styles.pickerItemPressed]} + onPress={() => { + onSelect(item) + onClose() + }} + > + {renderIcon?.(item)} + + {item.label} + + {selected && } + + + ) + })} + + + ) +} + +// ── Main modal ────────────────────────────────────────────────────── + +type Props = { + visible: boolean + client: RpcClient | null + // Why: existing worktree paths from the host so we can pick a unique + // marine-creature default when the user leaves the name blank, matching + // the desktop UI's behavior. The "already exists locally" collision is + // on the on-disk directory basename, so paths (not displayNames) are + // what the suggestion logic must dedupe against. + existingWorktreePaths?: readonly string[] + onCreated: (worktreeId: string, name: string) => void + onClose: () => void +} + +export function NewWorktreeModal({ + visible, + client, + existingWorktreePaths, + onCreated, + onClose +}: Props) { + const [repos, setRepos] = useState([]) + const [selectedRepo, setSelectedRepo] = useState(null) + const [showRepoPicker, setShowRepoPicker] = useState(false) + const [selectedAgent, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) + const [showAgentPicker, setShowAgentPicker] = useState(false) + const [name, setName] = useState('') + const [note, setNote] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [setupCommand, setSetupCommand] = useState(null) + const [setupSource, setSetupSource] = useState(null) + const [runSetup, setRunSetup] = useState(true) + const [creating, setCreating] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + + // Why: matches the desktop UI — the input shows a generic "Workspace name" + // placeholder, not the suggested creature. The creature name is only used + // as a server-bound fallback when the user submits with a blank field, so + // it's recomputed lazily inside handleCreate() to stay fresh against + // existingWorktreePaths at submission time. + + useEffect(() => { + if (!visible) { + setShowRepoPicker(false) + setShowAgentPicker(false) + return + } + if (!client) return + let stale = false + setName('') + setNote('') + setShowAdvanced(false) + setSetupCommand(null) + setSetupSource(null) + setRunSetup(true) + setError('') + setCreating(false) + setShowRepoPicker(false) + setShowAgentPicker(false) + setSelectedAgent(AGENT_OPTIONS[0]!) + setLoading(true) + + void (async () => { + try { + const response = await client.sendRequest('repo.list') + if (stale) return + if (response.ok) { + const result = (response as RpcSuccess).result as { repos: Repo[] } + setRepos(result.repos) + if (result.repos.length === 1) { + setSelectedRepo(result.repos[0]!) + } else { + setSelectedRepo(null) + } + } + } catch { + if (!stale) setRepos([]) + } finally { + if (!stale) setLoading(false) + } + })() + return () => { + stale = true + } + }, [visible, client]) + + useEffect(() => { + if (!client || !selectedRepo) { + setSetupCommand(null) + setSetupSource(null) + return + } + let stale = false + void (async () => { + try { + const response = await client.sendRequest('repo.hooks', { + repo: `id:${selectedRepo.id}` + }) + if (stale) return + if (response.ok) { + const result = (response as RpcSuccess).result as { + hooks: { scripts: { setup?: string } } | null + source: string | null + setupRunPolicy: string + } + const cmd = result.hooks?.scripts.setup ?? null + setSetupCommand(cmd) + setSetupSource(result.source) + setRunSetup(result.setupRunPolicy !== 'skip-by-default') + } + } catch { + if (!stale) { + setSetupCommand(null) + setSetupSource(null) + } + } + })() + return () => { + stale = true + } + }, [client, selectedRepo]) + + async function handleCreate() { + if (!client || !selectedRepo) return + setCreating(true) + setError('') + + try { + const command = + selectedAgent.id !== '__blank__' ? AGENT_COMMANDS[selectedAgent.id] : undefined + + // Why: blank name field — match desktop behavior by computing the + // next available marine-creature name at submit time and passing it + // to the server. The server's worktree.create rejects empty/invalid + // names, so we must generate one client-side rather than letting the + // server invent one. The pre-flight basename dedupe is only a hint; + // the authoritative collision is checked server-side against git + // branches/remotes/PRs, so we also retry-with-suffix on conflict. + const trimmedName = name.trim() + const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? []) + + // Why: mirrors src/renderer/src/store/slices/worktrees.ts + // (createWorktree retry loop). Server-side checks (Branch X already + // exists locally / on a remote / already has PR #N) can fire even + // after the pre-flight basename dedupe — branches outlive worktrees + // in git, and remote branches/PRs aren't visible from worktree.ps. + // Retry up to 25 times by appending -2, -3, ... before surfacing + // the error. The desktop applies this to user-typed names too, so + // mobile follows suit for parity. + const retryablePatterns = [ + /already exists locally/i, + /already exists on a remote/i, + /already has pr #\d+/i + ] + const candidateFor = (attempt: number): string => + attempt === 0 ? baseName : `${baseName}-${attempt + 1}` + + let lastError: string | null = null + for (let attempt = 0; attempt < 25; attempt += 1) { + const candidateName = candidateFor(attempt) + const params: Record = { + repo: `id:${selectedRepo.id}`, + startupCommand: command, + setupDecision: runSetup ? 'inherit' : 'skip', + name: candidateName + } + if (note.trim()) params.comment = note.trim() + + const response = await client.sendRequest('worktree.create', params) + if (response.ok) { + const result = (response as RpcSuccess).result as { worktree: { id: string } } + onClose() + onCreated(result.worktree.id, candidateName) + return + } + + lastError = response.error.message + if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) { + break + } + } + setError(lastError ?? 'Failed to create workspace') + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to create workspace') + } finally { + setCreating(false) + } + } + + const canCreate = selectedRepo != null && !creating + + return ( + <> + + + Create Workspace + + Pick a repository and agent to spin up a new workspace. + + + + {loading ? ( + + + + ) : repos.length === 0 ? ( + + No repositories found + + ) : ( + <> + + Repository + setShowRepoPicker(true)}> + + {selectedRepo?.displayName ?? 'Select a repository'} + + + + + + + + Workspace Name [Optional] + + { + setName(t) + setError('') + }} + placeholder="Workspace name" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + autoFocus={repos.length <= 1} + returnKeyType="done" + onSubmitEditing={() => { + if (canCreate) void handleCreate() + }} + /> + + + + Agent + setShowAgentPicker(true)}> + + + {selectedAgent.label} + + + + + + setShowAdvanced(!showAdvanced)}> + Advanced + {showAdvanced ? ( + + ) : ( + + )} + + + {showAdvanced && ( + <> + + Note + + + + {setupCommand ? ( + + + Setup script + {setupSource && ( + + + {setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'} + + + )} + + + + Run setup command + + + + {setupCommand} + + + + ) : null} + + )} + + {error ? {error} : null} + + + void handleCreate()} + > + {creating ? ( + + ) : ( + Create Workspace + )} + + + + )} + + + {/* Sub-modals for pickers — rendered outside the main modal so they + layer on top and scroll without touch conflicts. */} + ({ id: r.id, label: r.displayName, _repo: r }))} + selectedId={selectedRepo?.id ?? ''} + onSelect={(item) => setSelectedRepo((item as { _repo: Repo })._repo)} + onClose={() => setShowRepoPicker(false)} + /> + + setSelectedAgent(agent)} + onClose={() => setShowAgentPicker(false)} + renderIcon={(agent) => } + /> + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + subtitle: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + loadingContainer: { + paddingVertical: spacing.xl, + alignItems: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + fieldButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + fieldButtonText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + fieldButtonPlaceholder: { + color: colors.textMuted + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + error: { + color: colors.statusRed, + fontSize: 13, + marginBottom: spacing.md + }, + advancedToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + marginBottom: spacing.xs + }, + advancedText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textSecondary + }, + setupHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.xs + }, + sourceBadge: { + backgroundColor: colors.bgRaised, + borderRadius: 4, + paddingHorizontal: spacing.xs + 2, + paddingVertical: 2 + }, + sourceBadgeText: { + fontSize: 10, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5 + }, + setupBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md + }, + setupToggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + setupToggleLabel: { + fontSize: 13, + color: colors.textSecondary + }, + setupSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + }, + setupCommandBlock: { + backgroundColor: colors.bgBase, + borderRadius: 6, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.sm + }, + setupCommand: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: spacing.sm + }, + createButton: { + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + minWidth: 160, + alignItems: 'center' + }, + createButtonDisabled: { + opacity: 0.4 + }, + createText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + letterIcon: { + alignItems: 'center', + justifyContent: 'center' + }, + letterIconText: { + fontWeight: '700' + }, + + // Picker sub-modal styles + pickerHeader: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + pickerTitle: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + pickerGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + pickerSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + pickerList: { + flexGrow: 0 + }, + pickerItem: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + pickerItemPressed: { + backgroundColor: colors.bgRaised + }, + pickerItemText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + pickerItemTextSelected: { + fontWeight: '600' + } +}) diff --git a/mobile/src/components/OrcaLogo.tsx b/mobile/src/components/OrcaLogo.tsx new file mode 100644 index 00000000000..d05e4f2a968 --- /dev/null +++ b/mobile/src/components/OrcaLogo.tsx @@ -0,0 +1,23 @@ +import Svg, { G, Path, Defs } from 'react-native-svg' +import { colors } from '../theme/mobile-theme' + +type Props = { + size?: number + color?: string +} + +export function OrcaLogo({ size = 24, color = colors.textPrimary }: Props) { + const aspectRatio = 318.6 / 202.67 + const width = size * aspectRatio + return ( + + + + + + + ) +} diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx new file mode 100644 index 00000000000..eecc653e8da --- /dev/null +++ b/mobile/src/components/PickerModal.tsx @@ -0,0 +1,108 @@ +import { View, Text, Pressable, StyleSheet } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type PickerOption = { + value: T + label: string + subtitle?: string +} + +type Props = { + visible: boolean + title: string + options: PickerOption[] + selected: T + onSelect: (value: T) => void + onClose: () => void +} + +export function PickerModal({ + visible, + title, + options, + selected, + onSelect, + onClose +}: Props) { + return ( + + + {title} + + + + {options.map((opt, i) => { + const isSelected = opt.value === selected + return ( + + {i > 0 && } + [styles.row, pressed && styles.rowPressed]} + onPress={() => { + onSelect(opt.value) + onClose() + }} + > + + + {opt.label} + + {opt.subtitle ? {opt.subtitle} : null} + + {isSelected && } + + + ) + })} + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + rowLabelSelected: { + fontWeight: '600' + }, + rowSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + } +}) diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx new file mode 100644 index 00000000000..368976676f1 --- /dev/null +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -0,0 +1,105 @@ +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { router } from 'expo-router' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { CompatVerdict } from '../transport/protocol-compat' +import { MOBILE_PROTOCOL_VERSION } from '../transport/protocol-version' + +const RELEASES_URL = 'https://github.com/stablyai/orca/releases' + +type Props = { + verdict: Extract +} + +export function ProtocolBlockScreen({ verdict }: Props) { + const isMobileTooOld = verdict.reason === 'mobile-too-old' + + const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca desktop' + const body = isMobileTooOld + ? `The Orca desktop on this host requires Orca Mobile v${verdict.requiredMobileVersion ?? '?'}+. You have v${MOBILE_PROTOCOL_VERSION}.\n\nUpdate Orca Mobile from the App Store to continue.` + : `Orca Mobile requires Orca desktop v${verdict.requiredDesktopVersion ?? '?'}+ to use this host. The desktop is reporting v${verdict.desktopVersion}.` + + return ( + + + {title} + {body} + {/* Why: only desktop side has a deep-link target today — + App Store ID isn't published yet, so mobile-too-old is text-only. */} + {!isMobileTooOld ? ( + [styles.primaryButton, pressed && styles.pressed]} + onPress={() => { + void Linking.openURL(RELEASES_URL) + }} + > + Open GitHub Releases + + ) : null} + [styles.secondaryButton, pressed && styles.pressed]} + onPress={() => { + // Why: route back to the host list so the user can pair a + // different host instead of getting trapped on this screen. + router.replace('/') + }} + > + Pair a different host + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + justifyContent: 'center', + paddingHorizontal: spacing.lg + }, + card: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + padding: spacing.lg, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + title: { + fontSize: typography.titleSize, + fontWeight: '700', + color: colors.textPrimary, + marginBottom: spacing.sm + }, + body: { + fontSize: typography.bodySize, + color: colors.textSecondary, + lineHeight: 20, + marginBottom: spacing.lg + }, + primaryButton: { + backgroundColor: colors.textPrimary, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center', + marginBottom: spacing.sm + }, + primaryButtonText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.bgBase + }, + secondaryButton: { + backgroundColor: colors.bgRaised, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + secondaryButtonText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textPrimary + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/components/StatusDot.tsx b/mobile/src/components/StatusDot.tsx new file mode 100644 index 00000000000..a29b5e95eba --- /dev/null +++ b/mobile/src/components/StatusDot.tsx @@ -0,0 +1,43 @@ +import { View, StyleSheet } from 'react-native' +import { colors } from '../theme/mobile-theme' +import type { ConnectionState } from '../transport/types' +import type { ConnectionVerdict } from '../transport/connection-health' + +const stateColors: Record = { + connected: colors.statusGreen, + connecting: colors.statusAmber, + handshaking: colors.statusAmber, + reconnecting: colors.statusAmber, + disconnected: colors.textMuted, + 'auth-failed': colors.statusRed +} + +// Why: when caller passes a verdict, the dot color reflects the verdict's +// severity instead of the raw transport state. This avoids the "amber dot +// next to red 'Can't reach desktop' label" mismatch — the underlying +// transport is still 'reconnecting' (amber) but the user-visible meaning +// has escalated to error (red). +export function StatusDot({ + state, + verdict +}: { + state: ConnectionState + verdict?: ConnectionVerdict +}) { + const color = + verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed' + ? colors.statusRed + : verdict?.kind === 'warning' + ? colors.statusAmber + : (stateColors[state] ?? colors.textMuted) + return +} + +const styles = StyleSheet.create({ + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 8 + } +}) diff --git a/mobile/src/components/TextInputModal.tsx b/mobile/src/components/TextInputModal.tsx new file mode 100644 index 00000000000..ae6f3ee3e7f --- /dev/null +++ b/mobile/src/components/TextInputModal.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react' +import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +type Props = { + visible: boolean + title: string + message?: string + defaultValue?: string + placeholder?: string + onSubmit: (value: string) => void + onCancel: () => void +} + +export function TextInputModal({ + visible, + title, + message, + defaultValue = '', + placeholder, + onSubmit, + onCancel +}: Props) { + const [value, setValue] = useState(defaultValue) + + useEffect(() => { + if (visible) setValue(defaultValue) + }, [visible, defaultValue]) + + function handleSubmit() { + if (value.trim()) { + onSubmit(value.trim()) + } + } + + return ( + + + {title} + {message ? {message} : null} + + + + + + [styles.cancelButton, pressed && styles.buttonPressed]} + onPress={onCancel} + > + Cancel + + [ + styles.submitButton, + pressed && styles.buttonPressed, + !value.trim() && styles.submitButtonDisabled + ]} + disabled={!value.trim()} + onPress={handleSubmit} + > + Save + + + + ) +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + message: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + // Why: matches NewWorktreeModal's input — bgRaised on the modal + // background reads as a tappable surface (brighter than the wrapper) + // rather than a recessed pit (darker than the wrapper, which is what + // bgBase looked like inside a bgPanel group). + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.md + }, + cancelButton: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + submitButton: { + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + buttonPressed: { + opacity: 0.7 + }, + submitButtonDisabled: { + opacity: 0.4 + }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + submitText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/worktree-name-suggestion.ts b/mobile/src/components/worktree-name-suggestion.ts new file mode 100644 index 00000000000..59ec500f603 --- /dev/null +++ b/mobile/src/components/worktree-name-suggestion.ts @@ -0,0 +1,42 @@ +import { MARINE_CREATURES } from '../constants/marine-creatures' + +// Why: matches the desktop fallback in +// src/renderer/src/components/sidebar/worktree-name-suggestions.ts. The +// "already exists locally" collision is on the on-disk worktree directory +// name (the path basename), not the user-facing displayName — so we derive +// the used set from path basenames just like the desktop does. + +function stripTrailingSeparators(p: string): string { + return p.replace(/[\\/]+$/, '') +} + +// Why: cross-platform path basename — handles both POSIX ("/") and Windows +// ("\\") separators, mirroring src/renderer/src/lib/path.ts so the mobile +// suggestion logic agrees with the desktop's collision check. +export function pathBasename(p: string): string { + const normalized = stripTrailingSeparators(p) + const idx = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\')) + return idx === -1 ? normalized : normalized.slice(idx + 1) +} + +function normalize(name: string): string { + return name.trim().toLowerCase() +} + +export function getSuggestedCreatureName(existingPaths: readonly string[]): string { + const used = new Set() + for (const p of existingPaths) { + used.add(normalize(pathBasename(p))) + } + for (const candidate of MARINE_CREATURES) { + if (!used.has(normalize(candidate))) return candidate + } + let suffix = 2 + while (true) { + for (const candidate of MARINE_CREATURES) { + const numbered = `${candidate}-${suffix}` + if (!used.has(normalize(numbered))) return numbered + } + suffix += 1 + } +} diff --git a/mobile/src/constants/marine-creatures.ts b/mobile/src/constants/marine-creatures.ts new file mode 100644 index 00000000000..c3f1df7f024 --- /dev/null +++ b/mobile/src/constants/marine-creatures.ts @@ -0,0 +1,262 @@ +export const MARINE_CREATURES = [ + 'Nautilus', + 'Seahorse', + 'Starfish', + 'Coral', + 'Narwhal', + 'Jellyfish', + 'Octopus', + 'Manta', + 'Dolphin', + 'Manatee', + 'Cuttlefish', + 'Anemone', + 'Urchin', + 'Triton', + 'Nudibranch', + 'Coelacanth', + 'Oarfish', + 'Beluga', + 'Dugong', + 'Porpoise', + 'Stingray', + 'Hammerhead', + 'Wobbegong', + 'Sawfish', + 'Chimaera', + 'Anglerfish', + 'Viperfish', + 'Dragonfish', + 'Axolotl', + 'Otter', + 'Seadragon', + 'Seahare', + 'Squid', + 'Argonaut', + 'Sponge', + 'Barnacle', + 'Cowrie', + 'Guitarfish', + 'Lamprey', + 'Isopod', + 'Lionfish', + 'Clownfish', + 'Angelfish', + 'Butterflyfish', + 'Parrotfish', + 'Pufferfish', + 'Moonfish', + 'Firefish', + 'Unicornfish', + 'Rainbowfish', + 'Betta', + 'Discus', + 'Arowana', + 'Koi', + 'Piranha', + 'Barracuda', + 'Moray', + 'Sunfish', + 'Lanternfish', + 'Archerfish', + 'Mudskipper', + 'Hatchetfish', + 'Knifefish', + 'Leaffish', + 'Glassfish', + 'Ropefish', + 'Bichir', + 'Tigerfish', + 'Cardinalfish', + 'Lungfish', + 'Opah', + 'Frogfish', + 'Stonefish', + 'Cutlassfish', + 'Paddlefish', + 'Arapaima', + 'Mandarin', + 'Blobfish', + 'Thresher', + 'Vaquita', + 'Pipefish', + 'Guppy', + 'Tetra', + 'Danio', + 'Cichlid', + 'Oscar', + 'Gourami', + 'Killifish', + 'Rasbora', + 'Pleco', + 'Goldfish', + 'Molly', + 'Platy', + 'Barb', + 'Loach', + 'Medaka', + 'Pacu', + 'Filefish', + 'Boxfish', + 'Cowfish', + 'Surgeonfish', + 'Damselfish', + 'Wrasse', + 'Goby', + 'Blenny', + 'Conger', + 'Sculpin', + 'Darter', + 'Remora', + 'Pilotfish', + 'Trumpetfish', + 'Cornetfish', + 'Jawfish', + 'Toadfish', + 'Pearlfish', + 'Driftfish', + 'Lumpfish', + 'Snailfish', + 'Stickleback', + 'Halfbeak', + 'Snipefish', + 'Pencilfish', + 'Snakehead', + 'Tripletail', + 'Lookdown', + 'Sweetlips', + 'Squirrelfish', + 'Soldierfish', + 'Rabbitfish', + 'Hawkfish', + 'Bannerfish', + 'Batfish', + 'Chromis', + 'Anthias', + 'Fusilier', + 'Sweeper', + 'Emperor', + 'Ponyfish', + 'Kelpfish', + 'Weever', + 'Pearlside', + 'Opaleye', + 'Ballyhoo', + 'Needlefish', + 'Triggerfish', + 'Gar', + 'Tarpon', + 'Sailfish', + 'Conch', + 'Walrus', + 'Seal', + 'Penguin', + 'Hagfish', + 'Gulper', + 'Hydra', + 'Krill', + 'Salp', + 'Tunicate', + 'Crinoid', + 'Polyp', + 'Limpet', + 'Whelk', + 'Bowfin', + 'Minnow', + 'Gudgeon', + 'Goldeye', + 'Mooneye', + 'Soapfish', + 'Leatherjacket', + 'Rudderfish', + 'Sabrefish', + 'Puffin', + 'Albatross', + 'Osprey', + 'Pelican', + 'Petrel', + 'Gannet', + 'Tern', + 'Cormorant', + 'Leatherback', + 'Hawksbill', + 'Loggerhead', + 'Dottyback', + 'Basslet', + 'Gramma', + 'Hamlet', + 'Hogfish', + 'Dartfish', + 'Foxface', + 'Shrimpfish', + 'Pyrosome', + 'Seapen', + 'Medusa', + 'Candlefish', + 'Silverside', + 'Shiner', + 'Eelpout', + 'Madtom', + 'Shovelnose', + 'Sandlance', + 'Tubesnout', + 'Marlin', + 'Bonefish', + 'Dace', + 'Rudd', + 'Spirula', + 'Permit', + 'Grunt', + 'Trevally', + 'Croaker', + 'Drum', + 'Ladyfish', + 'Bullhead', + 'Torpedo', + 'Skate', + 'Hermit', + 'Fiddler', + 'Fireworm', + 'Tubeworm', + 'Amphipod', + 'Copepod', + 'Pteropod', + 'Seaslug', + 'Swordtail', + 'Bluegill', + 'Pickerel', + 'Redhorse', + 'Logperch', + 'Goblin', + 'Catshark', + 'Harlequin', + 'Murex', + 'Volute', + 'Wentletrap', + 'Nerite', + 'Stargazer', + 'Handfish', + 'Flyingfish', + 'Waspfish', + 'Skua', + 'Shearwater', + 'Guillemot', + 'Razorbill', + 'Auk', + 'Fulmar', + 'Murre', + 'Velvetfish', + 'Prowfish', + 'Sandperch', + 'Bobtail', + 'Chiton', + 'Featherstar', + 'Nereid', + 'Siren', + 'Kraken', + 'Selkie', + 'Leviathan', + 'Wolffish', + 'Wreckfish', + 'Horseshoe' +] as const diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts new file mode 100644 index 00000000000..987eed64e83 --- /dev/null +++ b/mobile/src/notifications/mobile-notifications.ts @@ -0,0 +1,121 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import { loadPushNotificationsEnabled } from '../storage/preferences' + +type NotificationEvent = { + type: 'notification' + source: 'agent-task-complete' | 'terminal-bell' | 'test' + title: string + body: string + worktreeId?: string +} + +type SubscribeResult = { + type: 'ready' + subscriptionId: string +} + +export type NotificationPermissionState = { + granted: boolean + status: string + canAskAgain: boolean +} + +export async function getNotificationPermissionState(): Promise { + const { status, canAskAgain } = await Notifications.getPermissionsAsync() + return { + granted: status === 'granted', + status, + canAskAgain + } +} + +// Why: permissions must be requested before scheduling any local notification. +// Read the OS state every time because users can change it in Settings while +// Orca remains alive in the background. +export async function ensureNotificationPermissions(): Promise { + const existing = await getNotificationPermissionState() + if (existing.granted) { + return true + } + + const { status } = await Notifications.requestPermissionsAsync() + return status === 'granted' +} + +function configureNotificationChannel(): void { + if (Platform.OS === 'android') { + void Notifications.setNotificationChannelAsync('orca-desktop', { + name: 'Desktop Notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250], + lightColor: '#6366f1' + }) + } +} + +async function showLocalNotification(event: NotificationEvent): Promise { + const enabled = await loadPushNotificationsEnabled() + if (!enabled) return + + const granted = await ensureNotificationPermissions() + if (!granted) return + + await Notifications.scheduleNotificationAsync({ + content: { + title: event.title, + body: event.body, + data: { source: event.source, worktreeId: event.worktreeId }, + ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) + }, + trigger: null + }) +} + +// Why: each host connection gets its own notification subscription. When the +// connection drops, the unsubscribe function cleans up the streaming RPC. +// Returns an unsubscribe function. +export function subscribeToDesktopNotifications(client: RpcClient): () => void { + configureNotificationChannel() + + let subscriptionId: string | null = null + let disposed = false + function unsubscribeServer(id: string) { + if (client.getState() === 'connected') { + client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {}) + } + } + + const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => { + const event = data as NotificationEvent | SubscribeResult | { type: 'end' } + if (event.type === 'ready') { + subscriptionId = (event as SubscribeResult).subscriptionId + if (disposed) { + unsubscribeServer(subscriptionId) + unsubscribeStream() + } + return + } + if (event.type === 'end') { + if (disposed) unsubscribeStream() + return + } + if (disposed) return + if (event.type === 'notification') { + void showLocalNotification(event as NotificationEvent) + } + }) + + return () => { + disposed = true + // Why: the client may already be closed when this cleanup runs (component + // unmount races with disconnect). sendRequest rejects immediately on a + // closed client — swallow it since server-side cleanup happens via + // connection-close anyway. + if (subscriptionId) { + unsubscribeStream() + unsubscribeServer(subscriptionId) + } + } +} diff --git a/mobile/src/platform/haptics.ts b/mobile/src/platform/haptics.ts new file mode 100644 index 00000000000..265283898d9 --- /dev/null +++ b/mobile/src/platform/haptics.ts @@ -0,0 +1,45 @@ +import { Platform } from 'react-native' +import * as Haptics from 'expo-haptics' + +export function triggerMediumImpact(): void { + if (Platform.OS === 'android') { + // Why: Android's Vibrator API (used by impactAsync) is unreliable for haptic + // feedback. performAndroidHapticsAsync uses the native HapticFeedbackConstants + // API which works without VIBRATE permission and feels more natural. + void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Long_Press).catch(() => {}) + } else { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}) + } +} + +export function triggerSelection(): void { + if (Platform.OS === 'android') { + void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Gesture_Start).catch(() => {}) + } else { + void Haptics.selectionAsync().catch(() => {}) + } +} + +export function triggerSuccess(): void { + if (Platform.OS === 'android') { + void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Confirm).catch(() => {}) + } else { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) + } +} + +export function triggerError(): void { + if (Platform.OS === 'android') { + void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Reject).catch(() => {}) + } else { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}) + } +} + +export function triggerEdgeBump(): void { + if (Platform.OS === 'android') { + void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Clock_Tick).catch(() => {}) + } else { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}) + } +} diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts new file mode 100644 index 00000000000..7a588902d86 --- /dev/null +++ b/mobile/src/storage/preferences.ts @@ -0,0 +1,93 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const PINS_PREFIX = 'orca:pins:' +const PREFS_PREFIX = 'orca:prefs:' +const NOTIF_KEY = 'orca:pushNotificationsEnabled' + +// Why: default-off so the iOS notification permission prompt never +// fires until the user explicitly opts in via Settings → Notifications. +// Apple's review guideline 4.5.4 and HIG both prefer user-initiated +// permission prompts; default-on would fire the prompt the moment the +// desktop sent its first notification, which can read as unsolicited. +export async function loadPushNotificationsEnabled(): Promise { + try { + const raw = await AsyncStorage.getItem(NOTIF_KEY) + if (raw === null) return false + return raw === 'true' + } catch { + return false + } +} + +export async function savePushNotificationsEnabled(enabled: boolean): Promise { + await AsyncStorage.setItem(NOTIF_KEY, String(enabled)) +} + +export type HostPreferences = { + sortMode: string + filterMode: string + groupMode: string + collapsedGroups: string[] + selectedRepos: string[] +} + +const DEFAULT_PREFS: HostPreferences = { + sortMode: 'smart', + filterMode: 'all', + groupMode: 'none', + collapsedGroups: [], + selectedRepos: [] +} +const SORT_MODES = new Set(['smart', 'recent', 'name']) +const FILTER_MODES = new Set(['all', 'active']) +const GROUP_MODES = new Set(['none', 'repo', 'prStatus']) + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +function allowedString(value: unknown, allowed: Set, fallback: string): string { + return typeof value === 'string' && allowed.has(value) ? value : fallback +} + +export async function loadPinnedIds(hostId: string): Promise> { + try { + const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId) + if (!raw) return new Set() + return new Set(stringArray(JSON.parse(raw))) + } catch { + return new Set() + } +} + +export async function savePinnedIds(hostId: string, ids: Set): Promise { + await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids])) +} + +export async function loadPreferences(hostId: string): Promise { + try { + const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId) + if (!raw) return DEFAULT_PREFS + const parsed = JSON.parse(raw) as Partial + return { + sortMode: allowedString(parsed.sortMode, SORT_MODES, DEFAULT_PREFS.sortMode), + filterMode: allowedString(parsed.filterMode, FILTER_MODES, DEFAULT_PREFS.filterMode), + groupMode: allowedString(parsed.groupMode, GROUP_MODES, DEFAULT_PREFS.groupMode), + collapsedGroups: stringArray(parsed.collapsedGroups), + selectedRepos: stringArray(parsed.selectedRepos) + } + } catch { + return DEFAULT_PREFS + } +} + +export async function savePreferences( + hostId: string, + prefs: Partial +): Promise { + const current = await loadPreferences(hostId) + const merged = { ...current, ...prefs } + await AsyncStorage.setItem(PREFS_PREFIX + hostId, JSON.stringify(merged)) +} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx new file mode 100644 index 00000000000..52e199d0e8d --- /dev/null +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -0,0 +1,1459 @@ +import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react' +import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native' +import { WebView } from 'react-native-webview' +import type { WebViewMessageEvent } from 'react-native-webview' +import { colors } from '../theme/mobile-theme' + +export type TerminalModes = { + bracketedPasteMode: boolean + altScreen: boolean +} + +export type TerminalSelectionEvents = { + onSelectionMode?: (active: boolean) => void + onSelectionCopy?: (text: string) => void + onSelectionEvicted?: () => void + onModesChanged?: (modes: TerminalModes) => void + onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void +} + +export type TerminalWebViewHandle = { + write: (data: string) => void + init: (cols: number, rows: number, initialData?: string) => void + clear: () => void + measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> + resetZoom: () => void + cancelSelect: () => void + doSelectAll: () => void + // Why: lets callers await the WebView-side `init` rAF chain (term.open + // → renderService population → first paint) so a follow-up measure + // doesn't race ahead and find term=null or cellWidth=0. Resolves on + // the next 'ready' notify after the most recent init. + awaitReady: () => Promise +} + +type Props = { + style?: StyleProp + onWebReady?: () => void +} & TerminalSelectionEvents + +type TerminalMessage = + | { type: 'write'; id?: number; data: string } + | { type: 'init'; id?: number; cols: number; rows: number; initialData?: string } + | { type: 'clear'; id?: number } + | { type: 'measure'; id?: number; containerHeight?: number } + | { type: 'reset-zoom'; id?: number } + | { type: 'cancel-select'; id?: number } + | { type: 'do-select-all'; id?: number } + +// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor +// positioning designed for the desktop's terminal dimensions (~150+ cols). +// We initialize xterm at the desktop's exact cols/rows so those escape codes +// render correctly, then use a measured CSS transform: scale() to fit the +// canvas into the phone viewport. The scale is computed after xterm opens +// by measuring the rendered surface width, not hardcoded, so it adapts to +// any terminal column count (80, 150, 200+). All touch gestures (scroll, +// pinch-to-zoom, pan) are handled by custom JS rather than native WebView +// behavior, so they work correctly with the CSS scale transform. +const XTERM_HTML = ` + + + + + + + + +
+
+
+
+
+
+
+ + +
+
+ + + +` + +export const TerminalWebView = forwardRef(function TerminalWebView( + { + style, + onWebReady, + onSelectionMode, + onSelectionCopy, + onSelectionEvicted, + onModesChanged, + onHaptic + }, + ref +) { + const webViewRef = useRef(null) + const isWebReadyRef = useRef(false) + const pendingMessagesRef = useRef([]) + const messageIdRef = useRef(0) + const measureResolveRef = useRef< + ((result: { cols: number; rows: number } | null) => void) | null + >(null) + // Why: each init() call posts 'init' to the WebView and arms a fresh + // ready promise. WebView's init() rAF chain ends with a 'ready' notify + // that resolves it. measureFitDimensions awaits this so it doesn't + // race ahead of term.open() / renderService population. + const readyPromiseRef = useRef | null>(null) + const readyResolveRef = useRef<(() => void) | null>(null) + + const sendToWebView = useCallback((msg: TerminalMessage) => { + messageIdRef.current += 1 + webViewRef.current?.postMessage(JSON.stringify({ ...msg, id: messageIdRef.current })) + }, []) + + const flushPendingMessages = useCallback(() => { + const pending = pendingMessagesRef.current + pendingMessagesRef.current = [] + for (const msg of pending) { + sendToWebView(msg) + } + }, [sendToWebView]) + + const postMessage = useCallback( + (msg: TerminalMessage) => { + if (!isWebReadyRef.current) { + pendingMessagesRef.current.push(msg) + return + } + sendToWebView(msg) + }, + [sendToWebView] + ) + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let msg: Record + try { + msg = JSON.parse(event.nativeEvent.data) as Record + } catch { + return + } + + if (msg.type === 'web-ready') { + isWebReadyRef.current = true + onWebReady?.() + flushPendingMessages() + } else if (msg.type === 'ready') { + // Why: the WebView's init() rAF chain has run — term is open, + // renderService is populated, first paint has happened. Resolve + // any pending awaitReady() so a queued measure can now safely + // read cell dims. + const resolve = readyResolveRef.current + readyResolveRef.current = null + readyPromiseRef.current = null + resolve?.() + } else if (msg.type === 'measure-result') { + const resolve = measureResolveRef.current + measureResolveRef.current = null + if (resolve) { + const cols = typeof msg.cols === 'number' ? msg.cols : null + const rows = typeof msg.rows === 'number' ? msg.rows : null + resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null) + } + } else if (msg.type === 'log') { + // Surface fit-scale diagnostics in the RN/Metro console. + const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]' + // eslint-disable-next-line no-console + console.log(tag, msg.payload) + } else if (msg.type === 'set-select-mode') { + onSelectionMode?.(!!msg.enabled) + } else if (msg.type === 'selection') { + const text = typeof msg.text === 'string' ? msg.text : '' + onSelectionCopy?.(text) + } else if (msg.type === 'selection-evicted') { + onSelectionEvicted?.() + } else if (msg.type === 'modes') { + onModesChanged?.({ + bracketedPasteMode: !!msg.bracketedPasteMode, + altScreen: !!msg.altScreen + }) + } else if (msg.type === 'haptic') { + const kind = msg.kind + if ( + kind === 'selection' || + kind === 'success' || + kind === 'error' || + kind === 'edge-bump' + ) { + onHaptic?.(kind) + } + } else if (msg.type === 'mobile-clip-cancel-by-pinch') { + // eslint-disable-next-line no-console + console.warn('[mobile-clip] selection cancelled by pinch') + } + }, + [ + flushPendingMessages, + onWebReady, + onSelectionMode, + onSelectionCopy, + onSelectionEvicted, + onModesChanged, + onHaptic + ] + ) + + const handleLoadStart = useCallback(() => { + isWebReadyRef.current = false + }, []) + + useImperativeHandle( + ref, + () => ({ + write(data: string) { + postMessage({ type: 'write', data }) + }, + init(cols: number, rows: number, initialData?: string) { + // Why: arm a fresh ready promise BEFORE posting init. The WebView + // resolves it via the 'ready' notify at the end of its rAF chain. + // Resolve any prior in-flight ready first so awaiters from the + // previous generation don't sit on the 3s setTimeout fallback — + // each leaked timer + closure pinned an awaiting measure caller + // for the full 3s under rapid re-init (orientation change, + // multiple resubscribes), delaying cold-start fit chains. + const priorResolve = readyResolveRef.current + if (priorResolve) { + readyResolveRef.current = null + readyPromiseRef.current = null + priorResolve() + } + readyPromiseRef.current = new Promise((resolve) => { + readyResolveRef.current = resolve + }) + postMessage({ type: 'init', cols, rows, initialData }) + }, + clear() { + postMessage({ type: 'clear' }) + }, + measureFitDimensions( + containerHeight?: number + ): Promise<{ cols: number; rows: number } | null> { + if (!isWebReadyRef.current) return Promise.resolve(null) + return new Promise((resolve) => { + measureResolveRef.current?.(null) + measureResolveRef.current = resolve + sendToWebView({ type: 'measure', containerHeight }) + // Why: if the WebView doesn't respond within 2s (e.g., xterm + // failed to load), resolve null so the caller can disable + // Fit to Phone rather than hanging indefinitely. + setTimeout(() => { + if (measureResolveRef.current === resolve) { + measureResolveRef.current = null + resolve(null) + } + }, 2000) + }) + }, + resetZoom() { + postMessage({ type: 'reset-zoom' }) + }, + cancelSelect() { + postMessage({ type: 'cancel-select' }) + }, + doSelectAll() { + postMessage({ type: 'do-select-all' }) + }, + async awaitReady(): Promise { + // Why: returns the in-flight ready promise (set by init); resolves + // immediately if no init is pending. Capped at 3s so a stuck + // WebView doesn't hang the caller. + const p = readyPromiseRef.current + if (!p) return + await Promise.race([p, new Promise((resolve) => setTimeout(resolve, 3000))]) + } + }), + [postMessage, sendToWebView] + ) + + return ( + + ) +}) + +const styles = StyleSheet.create({ + webview: { + flex: 1, + backgroundColor: colors.terminalBg + } +}) diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts new file mode 100644 index 00000000000..d0f80c671e0 --- /dev/null +++ b/mobile/src/theme/mobile-theme.ts @@ -0,0 +1,45 @@ +// Orca mobile design tokens — matches desktop graphite/dark palette. +// All screen files should import from here instead of using inline hex values. + +export const colors = { + bgBase: '#111111', + bgPanel: '#1a1a1a', + bgRaised: '#242424', + borderSubtle: '#2a2a2a', + + textPrimary: '#e0e0e0', + textSecondary: '#888888', + textMuted: '#555555', + + accentBlue: '#3b82f6', + + statusGreen: '#22c55e', + statusAmber: '#f59e0b', + statusRed: '#ef4444', + + // Terminal WebView background (Tokyonight) — separate from app chrome + terminalBg: '#1a1b26' +} as const + +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24 +} as const + +export const radii = { + row: 6, + card: 14, + button: 6, + input: 6, + camera: 8 +} as const + +export const typography = { + titleSize: 18, + bodySize: 14, + metaSize: 12, + monoFamily: 'monospace' as const +} as const diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx new file mode 100644 index 00000000000..8807c4c33e9 --- /dev/null +++ b/mobile/src/transport/client-context.tsx @@ -0,0 +1,476 @@ +// Why: collapses the per-screen WebSocket connection model into a single +// shared RpcClient per host. Implements the design in +// docs/mobile-shared-client-per-host.md. +// +// Lifecycle rules: +// - First request for a host opens its client lazily. +// - Refcount tracks active subscribers; when it drops to zero we schedule +// a 30-second idle close timer. If a new subscriber arrives within that +// window we cancel and reuse the same client. +// - removeHost() forces an immediate close so re-pairing gets a fresh +// transport. +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode +} from 'react' +import { connect, type RpcClient } from './rpc-client' +import { loadHosts } from './host-store' +import type { ConnectionState, HostProfile } from './types' + +type StoreEntry = { + client: RpcClient + state: ConnectionState + refCount: number + unsubState: () => void +} + +type ContextValue = { + acquire: (hostId: string, host?: HostProfile) => RpcClient | null + release: (hostId: string) => void + forceReconnect: (hostId: string) => Promise + closeHost: (hostId: string) => void + getState: (hostId: string) => ConnectionState + getReconnectAttempt: (hostId: string) => number + // Why: timestamp (ms epoch) of the last successful 'connected' state + // transition for this host, or null if never connected this session. + // Used by the UI to escalate "Reconnecting…" into a "host appears + // unreachable, re-pair?" prompt. + getLastConnectedAt: (hostId: string) => number | null + subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void + getAllClients: () => Array<{ hostId: string; client: RpcClient }> + subscribeAllHosts: (listener: () => void) => () => void + // Why: lets the home screen feed already-loaded HostProfiles in so we + // don't pay loadHosts() latency twice (once in the focus-effect, again + // inside openEntry). + primeHosts: (hosts: HostProfile[]) => void +} + +const Ctx = createContext(null) + +export function RpcClientProvider({ children }: { children: ReactNode }) { + // Why: entries live in a ref so updates don't force re-renders of the + // entire tree on every connection state change. State propagation goes + // through per-host listener Sets instead. + const storeRef = useRef>(new Map()) + const stateListenersRef = useRef void>>>(new Map()) + const allHostsListenersRef = useRef void>>(new Set()) + + // Pending opens (avoid two acquire() callers in the same render racing the + // host lookup). Keyed by hostId, value is a sentinel resolved when the + // entry materialises. + const pendingOpensRef = useRef>>(new Map()) + + // Why: a fast-path cache of already-loaded HostProfiles. Screens that + // have run loadHosts() can call primeHosts() to populate this and skip + // the second loadHosts() inside openEntry. Without this we'd serialize + // two Keychain passes on cold start. + const primedHostsRef = useRef>(new Map()) + + function notifyHostState(hostId: string, state: ConnectionState) { + const set = stateListenersRef.current.get(hostId) + if (!set) return + for (const listener of set) listener(state) + } + + function notifyAllHosts() { + for (const listener of allHostsListenersRef.current) listener() + } + + const closeEntry = useCallback((hostId: string) => { + const entry = storeRef.current.get(hostId) + if (!entry) return + entry.unsubState() + entry.client.close() + storeRef.current.delete(hostId) + notifyHostState(hostId, 'disconnected') + notifyAllHosts() + }, []) + + const openEntry = useCallback(async (hostId: string): Promise => { + const existing = pendingOpensRef.current.get(hostId) + if (existing) { + await existing + return storeRef.current.get(hostId) ?? null + } + let resolve: () => void = () => {} + const promise = new Promise((res) => { + resolve = res + }) + pendingOpensRef.current.set(hostId, promise) + + try { + // Why: prefer the primed cache (populated by primeHosts when the + // screen already ran loadHosts) so we don't serialize a second + // Keychain pass behind the first one on cold start. + let host = primedHostsRef.current.get(hostId) + if (!host) { + try { + const hosts = await loadHosts() + host = hosts.find((h) => h.id === hostId) + } catch { + // Why: a Keychain failure on cold start (rare but observed — + // happens when iOS Keychain is mid-unlock or Android Keystore + // races the JS bridge). Surface it as 'disconnected' so the + // home card flips off the perma-spinner and the user can hit + // Reconnect from the action sheet to retry. + notifyHostState(hostId, 'disconnected') + notifyAllHosts() + return null + } + if (!host) return null + } + + // Re-check after any await — another acquire() may have completed. + const after = storeRef.current.get(hostId) + if (after) return after + + let client: RpcClient + try { + client = connect(host.endpoint, host.deviceToken, host.publicKeyB64) + } catch { + // Why: connect() can throw synchronously if the public key is + // malformed or the endpoint URL is invalid. Notify so the UI + // doesn't sit on a stale 'connecting' label forever. + notifyHostState(hostId, 'disconnected') + notifyAllHosts() + return null + } + const unsubState = client.onStateChange((state) => { + const cur = storeRef.current.get(hostId) + if (!cur) return + cur.state = state + notifyHostState(hostId, state) + }) + const entry: StoreEntry = { + client, + state: client.getState(), + refCount: 0, + unsubState + } + storeRef.current.set(hostId, entry) + notifyHostState(hostId, entry.state) + notifyAllHosts() + return entry + } finally { + pendingOpensRef.current.delete(hostId) + resolve() + } + }, []) + + // Why: `acquire` is the synchronous get-or-open. If the entry already + // exists, return its client immediately and bump the refcount. If not, + // kick off an async open (the consumer will subscribe via + // `subscribeHostState` and re-read once 'connecting' fires). Optionally + // accepts the HostProfile so the caller can avoid an extra loadHosts() + // pass inside openEntry. + const acquire = useCallback( + (hostId: string, host?: HostProfile): RpcClient | null => { + if (host) primedHostsRef.current.set(hostId, host) + const existing = storeRef.current.get(hostId) + if (existing) { + existing.refCount += 1 + return existing.client + } + // Trigger async open. The acquire-side will return null this tick and + // try again once the state listener fires; consumers are expected to + // call acquire() inside an effect that re-runs on state changes. + void openEntry(hostId).then((entry) => { + if (!entry) return + entry.refCount += 1 + }) + return null + }, + [openEntry] + ) + + const primeHosts = useCallback((hosts: HostProfile[]) => { + for (const host of hosts) primedHostsRef.current.set(host.id, host) + }, []) + + // Why: refcount dropping to 0 no longer triggers an idle-close. The + // app deliberately keeps live WebSockets open while the app itself is + // foregrounded — closing on transient navigation gaps was producing + // false 'disconnected' flashes when the user navigated home → host → + // back to home faster than React could re-acquire on the home side. + // Connections still close on: explicit user Disconnect, host removal, + // app backgrounding (OS-level socket suspension), and provider + // unmount (app shutdown). + const release = useCallback((hostId: string) => { + const entry = storeRef.current.get(hostId) + if (!entry) return + entry.refCount = Math.max(0, entry.refCount - 1) + }, []) + + const forceReconnect = useCallback( + async (hostId: string) => { + const entry = storeRef.current.get(hostId) + // Why: preserve refcount across the swap. If the user reaches + // forceReconnect via the Disconnect → Reconnect path, the entry + // was already closed and refCount=0; fall back to active listener + // count as a proxy for "screens still watching this host." + const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 + const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) + if (entry) { + entry.unsubState() + entry.client.close() + storeRef.current.delete(hostId) + } + const fresh = await openEntry(hostId) + if (fresh) fresh.refCount = savedRefCount + }, + [openEntry] + ) + + const getState = useCallback((hostId: string): ConnectionState => { + return storeRef.current.get(hostId)?.state ?? 'disconnected' + }, []) + + const getReconnectAttempt = useCallback((hostId: string): number => { + return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0 + }, []) + + const getLastConnectedAt = useCallback((hostId: string): number | null => { + return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null + }, []) + + const subscribeHostState = useCallback( + (hostId: string, listener: (state: ConnectionState) => void) => { + let set = stateListenersRef.current.get(hostId) + if (!set) { + set = new Set() + stateListenersRef.current.set(hostId, set) + } + set.add(listener) + return () => { + const s = stateListenersRef.current.get(hostId) + if (!s) return + s.delete(listener) + if (s.size === 0) stateListenersRef.current.delete(hostId) + } + }, + [] + ) + + const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => { + const out: Array<{ hostId: string; client: RpcClient }> = [] + for (const [hostId, entry] of storeRef.current) { + out.push({ hostId, client: entry.client }) + } + return out + }, []) + + const subscribeAllHosts = useCallback((listener: () => void) => { + allHostsListenersRef.current.add(listener) + return () => { + allHostsListenersRef.current.delete(listener) + } + }, []) + + // Close all clients on provider unmount (app shutdown). + // Why: deps must be empty so this cleanup ONLY runs on real unmount. + // Hot-reload re-evaluates this module, which makes closeEntry a new + // function identity. With [closeEntry] as deps, every Fast Refresh + // would tear down all open WebSockets, leaving screens holding closed + // clients and the user staring at a 'Reconnecting…' card. Reading + // storeRef.current and the locally-scoped closeEntry inside the + // cleanup is safe — the ref is stable across renders, and the + // function captured here will be the one defined in the same + // closure as this effect. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const store = storeRef.current + return () => { + for (const [hostId] of store) closeEntry(hostId) + } + }, []) + + const value = useMemo( + () => ({ + acquire, + release, + forceReconnect, + closeHost: closeEntry, + getState, + getReconnectAttempt, + getLastConnectedAt, + subscribeHostState, + getAllClients, + subscribeAllHosts, + primeHosts + }), + [ + acquire, + release, + forceReconnect, + closeEntry, + getState, + getReconnectAttempt, + getLastConnectedAt, + subscribeHostState, + getAllClients, + subscribeAllHosts, + primeHosts + ] + ) + + return {children} +} + +function useCtx(): ContextValue { + const ctx = useContext(Ctx) + if (!ctx) throw new Error('useHostClient must be used inside ') + return ctx +} + +// Why: the primary hook for screens. Acquires the shared client for a +// hostId on mount and releases on unmount. Re-renders when the host's +// connection state changes. +export function useHostClient(hostId: string | undefined): { + client: RpcClient | null + state: ConnectionState +} { + const ctx = useCtx() + const [, force] = useState(0) + const [state, setState] = useState(() => + hostId ? ctx.getState(hostId) : 'disconnected' + ) + const clientRef = useRef(null) + + useEffect(() => { + if (!hostId) { + clientRef.current = null + setState('disconnected') + return + } + let cancelled = false + // Subscribe before acquire so any state change during open is captured. + const unsub = ctx.subscribeHostState(hostId, (next) => { + if (cancelled) return + setState(next) + // Why: if the client was null at first acquire (async open), the + // first state change ('connecting'/'handshaking'/'connected') is our + // signal to re-read. + if (clientRef.current == null) { + const all = ctx.getAllClients() + const found = all.find((entry) => entry.hostId === hostId) + if (found) { + clientRef.current = found.client + force((n) => n + 1) + } + } + }) + const initial = ctx.acquire(hostId) + if (initial) { + clientRef.current = initial + setState(ctx.getState(hostId)) + } + return () => { + cancelled = true + unsub() + ctx.release(hostId) + clientRef.current = null + } + }, [ctx, hostId]) + + return { client: clientRef.current, state } +} + +// Why: home screen renders all paired hosts at once. Acquires each on +// mount, releases on unmount. The provider's refcounting ensures we +// don't double-open if a host-detail screen is also open. +export function useAllHostClients(hostIds: string[]): Array<{ + hostId: string + client: RpcClient + state: ConnectionState +}> { + const ctx = useCtx() + // Stable key so we don't tear down on every render of the array. + const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) + const [tick, setTick] = useState(0) + + useEffect(() => { + if (hostIds.length === 0) return + for (const id of hostIds) ctx.acquire(id) + const unsubs: Array<() => void> = [] + for (const id of hostIds) { + unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1))) + } + unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1))) + return () => { + for (const u of unsubs) u() + for (const id of hostIds) ctx.release(id) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]) + + return useMemo(() => { + const out: Array<{ hostId: string; client: RpcClient; state: ConnectionState }> = [] + for (const id of hostIds) { + const all = ctx.getAllClients().find((entry) => entry.hostId === id) + if (all) { + out.push({ hostId: id, client: all.client, state: ctx.getState(id) }) + } + } + return out + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, tick]) +} + +// Why: removeHost() in host-store.ts must close the live client, but +// host-store has no React-side handle. Expose a hook that lets callers +// close a host after removal. +export function useCloseHost(): (hostId: string) => void { + const ctx = useCtx() + return ctx.closeHost +} + +// Why: future-proof "Connection issues — try again" affordance. +export function useForceReconnect(): (hostId: string) => Promise { + const ctx = useCtx() + return ctx.forceReconnect +} + +// Why: lets the home screen feed already-loaded HostProfiles in so the +// provider can skip its own loadHosts() pass when it eventually opens +// each host — collapses two serial Keychain reads on cold-start into one. +export function usePrimeHosts(): (hosts: HostProfile[]) => void { + const ctx = useCtx() + return ctx.primeHosts +} + +// Why: lets the home/host-detail UI escalate "Reconnecting…" to a more +// alarming "Can't connect" once the rpc-client has cycled enough times to +// indicate something's actually wrong (wrong port, server down, network +// loss). Reads through the context so it stays in sync with the live +// rpc-client instance even after forceReconnect swaps the underlying +// client. +export function useReconnectAttempt(hostId: string | undefined): number { + const ctx = useCtx() + const [, force] = useState(0) + useEffect(() => { + if (!hostId) return + return ctx.subscribeHostState(hostId, () => force((n) => n + 1)) + }, [ctx, hostId]) + return hostId ? ctx.getReconnectAttempt(hostId) : 0 +} + +// Why: timestamp of last successful connect for this host, or null if +// the client has never connected. Combined with reconnectAttempt this +// distinguishes "transient blip" (recently connected) from "host +// appears unreachable" (never connected, or hasn't connected in N +// seconds despite many retry attempts). +export function useLastConnectedAt(hostId: string | undefined): number | null { + const ctx = useCtx() + const [, force] = useState(0) + useEffect(() => { + if (!hostId) return + return ctx.subscribeHostState(hostId, () => force((n) => n + 1)) + }, [ctx, hostId]) + return hostId ? ctx.getLastConnectedAt(hostId) : null +} diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts new file mode 100644 index 00000000000..43bdc57b622 --- /dev/null +++ b/mobile/src/transport/connection-health.ts @@ -0,0 +1,88 @@ +import type { ConnectionState } from './types' + +// Why: thresholds for escalating connection UX from neutral +// "Reconnecting…" to alarming "host appears unreachable, re-pair?". +// +// - WARNING_ATTEMPTS: 3 → label flips to "Can't connect" (existing +// behavior). Calibrated to absorb a normal laptop wake / brief +// network blip without alarming the user. +// - UNREACHABLE_ATTEMPTS: 12 → with the tiered 0.5s→60s backoff this +// is ≈ 6 minutes of continuous failure (the last four attempts all +// reuse the 60s cap). Combined with the never-connected / +// stale-since-last-connect heuristic below, this is the trigger to +// surface a "re-pair?" affordance. MUST stay aligned with +// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS. +// - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this +// session but haven't been for ≥ 1 minute despite the retry loop +// spinning, treat the same as never-connected. Catches the case +// where the desktop's IP changed mid-session. +export const WARNING_ATTEMPTS = 3 +export const UNREACHABLE_ATTEMPTS = 12 +export const STALE_SINCE_LAST_CONNECT_MS = 60_000 + +export type ConnectionVerdict = + | { kind: 'normal'; label: string } + | { kind: 'warning'; label: string } // "Can't connect" + | { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' } + | { kind: 'auth-failed'; label: string } + +// Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have +// to recompute "are we currently stale" against now() each render. +// Centralized so home + host-detail show identical verdicts. +export function classifyConnection(args: { + state: ConnectionState + reconnectAttempts: number + lastConnectedAt: number | null + nowMs?: number +}): ConnectionVerdict { + const { state, reconnectAttempts, lastConnectedAt } = args + const now = args.nowMs ?? Date.now() + + if (state === 'auth-failed') { + return { kind: 'auth-failed', label: 'Auth failed' } + } + + // Connected / connecting / handshaking are normal. + if (state === 'connected') return { kind: 'normal', label: 'Connected' } + if (state === 'connecting' || state === 'handshaking') { + return { kind: 'normal', label: 'Connecting…' } + } + + if (state === 'disconnected') { + return { kind: 'normal', label: 'Disconnected' } + } + + // state === 'reconnecting' from here. + if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) { + if (lastConnectedAt == null) { + return { + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'never-connected' + } + } + if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { + return { + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'stale' + } + } + } + + if (reconnectAttempts >= WARNING_ATTEMPTS) { + return { kind: 'warning', label: "Can't connect" } + } + + return { kind: 'normal', label: 'Reconnecting…' } +} + +// Why: the message under the banner explains what likely happened so the +// user understands why we're suggesting Re-pair. Tuned to be specific +// about IP/port without being technical (we don't want to leak +// "ws://192.168.x.y:port" unless someone is debugging). +export function unreachableHint(reason: 'never-connected' | 'stale'): string { + return reason === 'never-connected' + ? "Can't reach this Orca desktop. Its network address may have changed since pairing — try re-pairing from the desktop's Settings → Mobile screen." + : 'Lost contact with the Orca desktop. If your network changed (different Wi-Fi, IP renewed, or desktop restarted), try re-pairing.' +} diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts new file mode 100644 index 00000000000..87d599ec077 --- /dev/null +++ b/mobile/src/transport/e2ee.ts @@ -0,0 +1,91 @@ +// Why: E2EE primitives for the mobile side. Uses tweetnacl for Curve25519 ECDH +// key exchange and XSalsa20-Poly1305 authenticated encryption. Wire format is +// base64([24-byte nonce][ciphertext]) over WebSocket text frames. +import nacl from 'tweetnacl' +import * as ExpoCrypto from 'expo-crypto' + +// Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues, +// which tweetnacl requires. expo-crypto provides a native secure RNG +// that works in Expo Go without a custom dev build. +nacl.setPRNG((_x: Uint8Array, n: number) => { + const bytes = ExpoCrypto.getRandomBytes(n) + _x.set(bytes) +}) + +// Why: tweetnacl uses `instanceof Uint8Array` checks internally. On Hermes, +// values from native modules (expo-crypto), TextEncoder, or typed array +// operations can return objects that fail this check despite being +// functionally identical. Wrapping with `new Uint8Array(x)` guarantees +// the correct prototype chain. +function u8(x: Uint8Array): Uint8Array { + return new Uint8Array(x) +} + +export function generateKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } { + const kp = nacl.box.keyPair() + return { publicKey: u8(kp.publicKey), secretKey: u8(kp.secretKey) } +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return u8(nacl.box.before(u8(peerPublicKey), u8(ourSecretKey))) +} + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!) + } + return btoa(binary) +} + +function base64ToUint8(b64: string): Uint8Array { + const binary = atob(b64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +export function publicKeyFromBase64(b64: string): Uint8Array { + const key = base64ToUint8(b64) + if (key.length !== 32) { + throw new Error( + `Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."` + ) + } + return key +} + +export function publicKeyToBase64(key: Uint8Array): string { + return uint8ToBase64(key) +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = u8(nacl.randomBytes(nacl.box.nonceLength)) + const messageBytes = u8(new TextEncoder().encode(plaintext)) + const ciphertext = nacl.box.after(messageBytes, nonce, u8(sharedKey)) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return uint8ToBase64(bundle) +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = base64ToUint8(encrypted) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = u8(bundle.subarray(0, nacl.box.nonceLength)) + const ciphertext = u8(bundle.subarray(nacl.box.nonceLength)) + const plaintext = nacl.box.open.after(ciphertext, nonce, u8(sharedKey)) + + if (!plaintext) { + return null + } + + return new TextDecoder().decode(plaintext) +} diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts new file mode 100644 index 00000000000..af21723a66f --- /dev/null +++ b/mobile/src/transport/host-store.ts @@ -0,0 +1,181 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { + HostProfileSchema, + StoredHostProfileSchema, + type HostProfile, + type StoredHostProfile +} from './types' + +const STORAGE_KEY = 'orca:hosts' +// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected. +// Use dots as the separator so the key shape stays readable while +// satisfying the validator. +const TOKEN_KEY_PREFIX = 'orca.host-token.' + +// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off +// iCloud Keychain and out of iCloud/iTunes backup restores onto a +// different physical device. Reads/writes are silent (no biometric +// prompt) since we don't request access control flags. +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +function tokenKey(hostId: string): string { + return `${TOKEN_KEY_PREFIX}${hostId}` +} + +// Why: SecureStore reads on Android Keystore can take 50-200ms each, and +// loadHosts() is called from every screen mount + every useFocusEffect. +// Stack with N hosts and you get N*200ms blocking every navigation, which +// triggers connection-churn cycles in the home-screen useEffect. Cache +// per-hostId in memory; invalidate only on save/remove. The cache lives +// for the JS-runtime lifetime, which matches AsyncStorage semantics +// (cleared on app uninstall, persisted across foreground/background). +const tokenCache = new Map() +let inflightLoad: Promise | null = null + +export async function loadHosts(): Promise { + // Why: deduplicate concurrent loadHosts() calls so multiple screens + // mounting simultaneously share one Keychain read pass. + if (inflightLoad) return inflightLoad + inflightLoad = doLoadHosts().finally(() => { + inflightLoad = null + }) + return inflightLoad +} + +async function doLoadHosts(): Promise { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return [] + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + + const out: HostProfile[] = [] + for (const item of parsed) { + // Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage. + // Drop them silently — the three pre-launch users will re-pair on + // first run rather than carry a migration shim through the auth + // path. + if (item && typeof item === 'object' && 'deviceToken' in item) { + continue + } + const stored = StoredHostProfileSchema.safeParse(item) + if (!stored.success) continue + + let token = tokenCache.get(stored.data.id) + if (!token) { + let fetched: string | null + try { + fetched = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS) + } catch { + // Why: a transient Keychain failure for one entry (e.g. + // errSecInteractionNotAllowed while the device is briefly locked, + // or a single corrupt record) must not blank the entire host list. + // Skip just this host — it'll reappear on the next load. + continue + } + if (!fetched) { + // Why: orphaned metadata with no matching keychain entry — most + // likely a stale record from a development install. Skip it + // rather than surface a half-broken host. + continue + } + token = fetched + tokenCache.set(stored.data.id, token) + } + out.push({ ...stored.data, deviceToken: token }) + } + return out +} + +async function loadStoredHosts(): Promise { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((item) => { + // Why: same drop-old-records rule as loadHosts; keeps internal + // mutators from re-persisting pre-v0.0.3 entries. + if (item && typeof item === 'object' && 'deviceToken' in item) return [] + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return [] + } +} + +function toStored(host: HostProfile): StoredHostProfile { + return { + id: host.id, + name: host.name, + endpoint: host.endpoint, + publicKeyB64: host.publicKeyB64, + lastConnected: host.lastConnected + } +} + +export async function saveHost(host: HostProfile): Promise { + const validated = HostProfileSchema.parse(host) + const hosts = await loadStoredHosts() + const stored = toStored(validated) + const index = hosts.findIndex((h) => h.id === stored.id) + if (index >= 0) { + hosts[index] = stored + } else { + hosts.push(stored) + } + // Why: write metadata BEFORE the keychain token so a crash between the two + // leaves orphaned metadata (which loadHosts skips and removeHost can clean + // up) rather than an orphaned keychain token with no metadata pointer — + // the latter would persist forever since removeHost only deletes by hostId + // from current metadata. + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + await SecureStore.setItemAsync(tokenKey(stored.id), validated.deviceToken, KEYCHAIN_OPTIONS) + tokenCache.set(stored.id, validated.deviceToken) +} + +export async function removeHost(hostId: string): Promise { + const hosts = await loadStoredHosts() + const filtered = hosts.filter((h) => h.id !== hostId) + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)) + await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + tokenCache.delete(hostId) +} + +export async function renameHost(hostId: string, newName: string): Promise { + const hosts = await loadStoredHosts() + const host = hosts.find((h) => h.id === hostId) + if (host) { + host.name = newName + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + } +} + +export async function getNextHostName(): Promise { + const hosts = await loadStoredHosts() + const existingNumbers = hosts + .map((h) => { + const match = h.name.match(/^Host (\d+)$/) + return match ? parseInt(match[1]!, 10) : 0 + }) + .filter((n) => n > 0) + const next = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1 + return `Host ${next}` +} + +export async function updateLastConnected(hostId: string): Promise { + const hosts = await loadStoredHosts() + const host = hosts.find((h) => h.id === hostId) + if (host) { + host.lastConnected = Date.now() + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) + } +} diff --git a/mobile/src/transport/index.ts b/mobile/src/transport/index.ts new file mode 100644 index 00000000000..8aacca59eff --- /dev/null +++ b/mobile/src/transport/index.ts @@ -0,0 +1,19 @@ +export { connect, type RpcClient } from './rpc-client' +export { + loadHosts, + saveHost, + removeHost, + renameHost, + getNextHostName, + updateLastConnected +} from './host-store' +export type { + RpcRequest, + RpcResponse, + RpcSuccess, + RpcFailure, + ConnectionState, + HostProfile, + PairingOffer +} from './types' +export { PairingOfferSchema, PAIRING_OFFER_VERSION } from './types' diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts new file mode 100644 index 00000000000..2b54f4e58db --- /dev/null +++ b/mobile/src/transport/pairing.ts @@ -0,0 +1,38 @@ +import { PairingOfferSchema, type PairingOffer } from './types' + +// Why: this file mirrors src/shared/pairing.ts (which is covered by CI +// vitest) but uses atob/btoa because Metro/Hermes don't ship Node's +// Buffer. Keep the parsing semantics in sync — when one changes, update +// the other. + +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)) + } catch { + 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 { + const trimmed = input.trim() + if (!trimmed) return null + try { + if (trimmed.startsWith('orca://pair')) { + return decodePairingUrl(trimmed) + } + return decodePairingBase64(trimmed) + } catch { + return null + } +} + +function decodePairingBase64(base64url: string): PairingOffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const json = atob(base64) + return PairingOfferSchema.parse(JSON.parse(json)) +} diff --git a/mobile/src/transport/protocol-compat.ts b/mobile/src/transport/protocol-compat.ts new file mode 100644 index 00000000000..12dc0de4093 --- /dev/null +++ b/mobile/src/transport/protocol-compat.ts @@ -0,0 +1,42 @@ +// Why: this file mirrors src/shared/protocol-compat.ts (which is +// covered by CI vitest). Metro can't resolve out of mobile/, so the +// pure function is duplicated here. Keep the two files in sync — when +// you change the evaluator's logic, update both. The src/shared/ copy +// is the tested canonical version. +import { MIN_COMPATIBLE_DESKTOP_VERSION, MOBILE_PROTOCOL_VERSION } from './protocol-version' + +export type CompatVerdict = + | { kind: 'ok' } + | { + kind: 'blocked' + reason: 'mobile-too-old' | 'desktop-too-old' + desktopVersion: number + requiredMobileVersion?: number + requiredDesktopVersion?: number + } + +export function evaluateCompat(input: { + desktopProtocolVersion: number | undefined + desktopMinCompatibleMobileVersion: number | undefined +}): CompatVerdict { + const desktopVersion = input.desktopProtocolVersion ?? 0 + const requiredMobile = input.desktopMinCompatibleMobileVersion ?? 0 + + if (MOBILE_PROTOCOL_VERSION < requiredMobile) { + return { + kind: 'blocked', + reason: 'mobile-too-old', + desktopVersion, + requiredMobileVersion: requiredMobile + } + } + if (desktopVersion < MIN_COMPATIBLE_DESKTOP_VERSION) { + return { + kind: 'blocked', + reason: 'desktop-too-old', + desktopVersion, + requiredDesktopVersion: MIN_COMPATIBLE_DESKTOP_VERSION + } + } + return { kind: 'ok' } +} diff --git a/mobile/src/transport/protocol-version.ts b/mobile/src/transport/protocol-version.ts new file mode 100644 index 00000000000..9445c1f2881 --- /dev/null +++ b/mobile/src/transport/protocol-version.ts @@ -0,0 +1,20 @@ +// Why: declares the mobile's pairing protocol version and the minimum +// desktop version it can talk to. Duplicates the desktop's +// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve +// outside `mobile/`. Manual sync is acceptable — these constants are +// expected to bump less than once a quarter. +// +// Bump MOBILE_PROTOCOL_VERSION when: +// - You change the meaning of an RPC mobile sends. +// - You stop relying on a desktop-side feature in a way old desktops +// would notice. +// Do NOT bump for: +// - Adding new optional fields to outbound requests. +// - Reading new optional fields on incoming responses. +// +// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a +// desktop feature added at a specific desktop protocol version. This +// triggers a hard-block screen for users paired to older desktops. + +export const MOBILE_PROTOCOL_VERSION = 1 +export const MIN_COMPATIBLE_DESKTOP_VERSION = 0 diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts new file mode 100644 index 00000000000..505a5722bd4 --- /dev/null +++ b/mobile/src/transport/rpc-client.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, '') +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSING = MockWebSocket.CLOSING + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onerror: (() => void) | null = null + emitCloseOnClose = true + sent: string[] = [] + close = vi.fn(() => { + if (this.readyState === MockWebSocket.CLOSED) return + this.readyState = MockWebSocket.CLOSED + if (this.emitCloseOnClose) { + this.onclose?.() + } + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + } + + receive(payload: string): void { + this.onmessage?.({ data: payload }) + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +describe('mobile rpc-client connection timeout', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('closes a socket that never opens so reconnect can run', () => { + const states: string[] = [] + const client = connect('ws://desktop.invalid', 'token', 'server-key', (state) => { + states.push(state) + }) + + expect(client.getState()).toBe('connecting') + expect(mockSockets).toHaveLength(1) + mockSockets[0]!.emitCloseOnClose = false + + vi.advanceTimersByTime(12_000) + + expect(mockSockets[0]!.close).toHaveBeenCalledTimes(1) + expect(client.getState()).toBe('reconnecting') + expect(states).toContain('reconnecting') + + client.close() + }) + + it('clears the open timeout once the socket opens and authenticates', () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + + expect(client.getState()).toBe('connected') + + vi.advanceTimersByTime(12_000) + + expect(socket.close).not.toHaveBeenCalled() + expect(client.getState()).toBe('connected') + + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts new file mode 100644 index 00000000000..d3e080441c3 --- /dev/null +++ b/mobile/src/transport/rpc-client.ts @@ -0,0 +1,815 @@ +import type { + RpcResponse, + RpcSuccess, + ConnectionState, + ConnectionLogLevel, + ConnectionLogSink +} from './types' +import { + generateKeyPair, + deriveSharedKey, + publicKeyFromBase64, + publicKeyToBase64, + encrypt, + decrypt +} from './e2ee' + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type StreamingListener = (result: unknown) => void + +type StreamRequest = { + method: string + params: unknown + listener: StreamingListener +} + +export type RpcClient = { + sendRequest: (method: string, params?: unknown) => Promise + subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void + getState: () => ConnectionState + // Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross + // a threshold. 0 means never failed; counter is reset on successful open. + getReconnectAttempt: () => number + // Why: timestamp (ms epoch) of the last time we reached 'connected'. + // null = never connected since the client was created. Used by the UI + // to distinguish "host moved/never reachable" from "transient blip". + getLastConnectedAt: () => number | null + onStateChange: (listener: (state: ConnectionState) => void) => () => void + close: () => void +} + +// Why: tiered backoff. The first four entries (500ms→4s) keep +// auto-recovery snappy for the common case — a brief Wi-Fi blip, +// laptop wake, or AP-isolation cycle. Beyond that we slow down +// (8s→60s) so a phone whose desktop is genuinely unreachable doesn't +// burn a TCP SYN every 4s indefinitely while still healing on its +// own when the network recovers. With 12 total attempts, the last +// four reuse the 60s cap (Math.min(idx, length-1)), so total elapsed +// time across all 12 attempts is ≈ 6 minutes before the give-up cap +// fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s). +const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] +// Why: cap auto-retry once we're clearly unreachable for a long time. +// With the tiered backoff above this is ≈ 6 minutes of continuous +// failure before we stop and surface the re-pair banner. The longer +// runway tolerates flaky AP-isolation routers and laptop sleep cycles +// that briefly drop the LAN path. MUST stay aligned with +// connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable" +// verdict matches the moment the loop actually pauses — if these +// drift the user sees "Reconnecting…" while the loop is silently +// parked. +const GIVE_UP_AFTER_ATTEMPTS = 12 +const REQUEST_TIMEOUT_MS = 30_000 +const CONNECT_TIMEOUT_MS = 12_000 +const HANDSHAKE_TIMEOUT_MS = 5_000 +// Why: RN's WebSocket implementation may not expose static readyState +// constants, but the protocol value for CONNECTING is stable across runtimes. +const WEBSOCKET_CONNECTING_STATE = 0 + +// Why: app-level liveness probe. The server runs its own ping/pong sweep +// at 15s, but RN's WebSocket runtime auto-pongs at the native layer +// without surfacing anything to JS — so the mobile side can't *see* that +// the server thinks the link is fine. To detect a half-open socket from +// the mobile direction (e.g. server crashed, phone moved between wifi +// and cellular without TCP RST) we periodically round-trip a tiny RPC. +// If two consecutive probes time out we force-close the WS, which fires +// the existing reconnect path. 20s cadence + the 30s request timeout = +// worst-case ~50s before mobile decides the link is dead and kicks +// reconnect, which is still inside the user's perceived "responsive" +// window and well below iOS's typical background-disconnect window. +const ACTIVITY_PROBE_INTERVAL_MS = 20_000 + +export type ConnectOptions = { + onStateChange?: (state: ConnectionState) => void + // Fires for every observable lifecycle event so the UI can render a + // detailed connection log. Useful when 'Connecting…' hangs forever + // (e.g. broken Tailscale route) and you need to see *where* it's stuck. + onLog?: ConnectionLogSink +} + +export function connect( + endpoint: string, + deviceToken: string, + serverPublicKeyB64: string, + optionsOrLegacy?: ConnectOptions | ((state: ConnectionState) => void) +): RpcClient { + // Why: keep backward-compat with callers that pass a bare onStateChange fn. + const options: ConnectOptions = + typeof optionsOrLegacy === 'function' + ? { onStateChange: optionsOrLegacy } + : (optionsOrLegacy ?? {}) + const onStateChange = options.onStateChange + const onLog = options.onLog + let logCounter = 0 + function emitLog(level: ConnectionLogLevel, message: string, detail?: string) { + if (!onLog) return + onLog({ + id: `log-${++logCounter}-${Date.now()}`, + ts: Date.now(), + level, + message, + detail + }) + } + let ws: WebSocket | null = null + let state: ConnectionState = 'disconnected' + let requestCounter = 0 + let reconnectAttempt = 0 + let reconnectTimer: ReturnType | null = null + let connectTimer: ReturnType | null = null + let handshakeTimer: ReturnType | null = null + let activityProbeTimer: ReturnType | null = null + let intentionallyClosed = false + let lastConnectedAt: number | null = null + // Why: diagnostic — when the rpc-client gets stuck in a state where every + // openConnection fails with code 1006 and only a force-quit recovers, we + // need to see whether (a) the new attempts even differ from the old ones, + // (b) anything is happening at the OS / RN-bridge layer between attempts, + // and (c) what the timing pattern is (instant 1006 = port closed / route + // dead, slow 1006 = packet drop / timeout). These three timestamps + the + // ws-construction counter are the cheapest visibility into RN/OkHttp + // process-state poisoning hypotheses. + let lastInboundAt: number | null = null + let lastWsClosedAt: number | null = null + let wsConstructionCounter = 0 + let currentWsOpenedAt: number | null = null + + // Why: fresh ephemeral keypair per connection provides forward secrecy. + // The shared key is derived from our ephemeral secret + server's static public key. + let sharedKey: Uint8Array | null = null + const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64) + + const pending = new Map() + const streamListeners = new Map() + const stateListeners = new Set<(state: ConnectionState) => void>() + const connectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [] + + if (onStateChange) { + stateListeners.add(onStateChange) + } + + // Diagnostic: tracks how long we've been in the current state. Useful + // for spotting "stuck in connecting" or "stuck in reconnecting" cases + // in the logs. + let stateEnteredAt = Date.now() + + function setState(next: ConnectionState) { + if (state === next) return + const prev = state + const dwelt = Date.now() - stateEnteredAt + state = next + stateEnteredAt = Date.now() + console.log('[net] state', { + from: prev, + to: next, + dweltMs: dwelt, + attempt: reconnectAttempt, + endpoint: redactedEndpoint(endpoint) + }) + if (next === 'connected') { + lastConnectedAt = Date.now() + for (const w of connectWaiters.splice(0)) w.resolve() + } else if (next === 'disconnected' || next === 'auth-failed') { + const reason = + next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed' + for (const w of connectWaiters.splice(0)) w.reject(new Error(reason)) + } + for (const listener of stateListeners) { + listener(next) + } + } + + // Why: don't dump device tokens / full URLs into log scrolls; truncate to + // the host:port so reconnect lifecycles are still readable. + function redactedEndpoint(ep: string): string { + try { + const m = ep.match(/^wss?:\/\/([^/]+)/i) + return m ? m[1] : 'unknown' + } catch { + return 'unknown' + } + } + + function waitForConnected(): Promise { + if (state === 'connected') return Promise.resolve() + if (intentionallyClosed) return Promise.reject(new Error('Client closed')) + return new Promise((resolve, reject) => { + connectWaiters.push({ resolve, reject }) + }) + } + + function nextId(): string { + return `rpc-${++requestCounter}-${Date.now()}` + } + + function openConnection() { + if (intentionallyClosed) return + + const now = Date.now() + wsConstructionCounter++ + console.log('[net] openConnection', { + attempt: reconnectAttempt, + endpoint: redactedEndpoint(endpoint), + // Why: process-poisoning diagnostic. If wsCount is high (e.g. >50) + // and every recent open fails with 1006, suspect RN/OkHttp internal + // pool corruption that only force-quit clears. Compare msSinceLast* + // values to the failure cadence: instant repeated fails with no + // inbound traffic between them = process-state stuck. + wsCount: wsConstructionCounter, + msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null, + msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null, + msSinceLastInbound: lastInboundAt != null ? now - lastInboundAt : null + }) + setState('connecting') + sharedKey = null + + currentWsOpenedAt = now + emitLog( + 'info', + reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket', + endpoint + ) + + ws = new WebSocket(endpoint) + const openingWs = ws + + // Why: React Native can leave TCP/WebSocket opens pending indefinitely on + // flaky network handoffs. Force the existing onclose reconnect path if + // onopen never arrives, instead of leaving the UI stuck at "Connecting...". + connectTimer = setTimeout(() => { + connectTimer = null + if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) { + console.log('[net] connect-timeout fired (onopen never arrived)', { + attempt: reconnectAttempt, + timeoutMs: CONNECT_TIMEOUT_MS + }) + emitLog( + 'error', + 'WebSocket connect timeout', + `No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?` + ) + openingWs.close() + if (ws === openingWs) { + handleSocketClosed(openingWs, { timedOut: true }) + } + } + }, CONNECT_TIMEOUT_MS) + + ws.onopen = () => { + console.log('[net] ws.onopen', { attempt: reconnectAttempt }) + clearConnectTimer() + reconnectAttempt = 0 + setState('handshaking') + emitLog('success', 'WebSocket open', 'Starting E2EE handshake') + + // Why: generate a fresh ephemeral keypair for each connection. + // This provides forward secrecy — compromising one session's key + // doesn't compromise past or future sessions. + const ephemeral = generateKeyPair() + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ephemeral.publicKey) + }) + ws?.send(hello) + emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready') + + sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey) + + handshakeTimer = setTimeout(() => { + handshakeTimer = null + console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', { + timeoutMs: HANDSHAKE_TIMEOUT_MS + }) + emitLog( + 'error', + 'Handshake timeout', + `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s` + ) + ws?.close() + }, HANDSHAKE_TIMEOUT_MS) + } + + ws.onmessage = (event) => { + // Why: track last-inbound for the openConnection diagnostic. Server + // pongs and stream events both bump this — anything from the wire. + lastInboundAt = Date.now() + const raw = typeof event.data === 'string' ? event.data : String(event.data) + + // Why: during handshaking, e2ee_ready is plaintext because it precedes + // encrypted auth; e2ee_authenticated/e2ee_error are encrypted. + if (state === 'handshaking') { + try { + const msg = JSON.parse(raw) + if (msg.type === 'e2ee_ready') { + emitLog('success', 'Received e2ee_ready', 'Sending device token') + sendEncrypted({ type: 'e2ee_auth', deviceToken }) + return + } + } catch { + // Not plaintext JSON — fall through and try encrypted handshake messages. + } + + if (!sharedKey || sharedKey.length !== 32) { + return + } + + const plaintext = decrypt(raw, sharedKey) + if (plaintext === null) { + return + } + + try { + const msg = JSON.parse(plaintext) + if (msg.type === 'e2ee_authenticated') { + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + console.log('[net] e2ee_authenticated — connected', { + streamCount: streamListeners.size + }) + setState('connected') + emitLog('success', 'Authenticated', 'Channel ready for RPC') + startActivityProbe() + for (const [id, stream] of streamListeners) { + sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params }) + } + } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { + console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error }) + emitLog( + 'error', + 'Authentication rejected', + typeof msg.error?.message === 'string' ? msg.error.message : 'Unauthorized' + ) + intentionallyClosed = true + ws?.close() + ws = null + setState('auth-failed') + rejectAllPending('Unauthorized — pairing may be revoked') + } + } catch { + // Not JSON — ignore during handshake. + } + return + } + + // Why: guard against decrypt with an invalid key — sharedKey can be null + // after destroy() or if a message arrives during a reconnect race. + if (!sharedKey || sharedKey.length !== 32) { + return + } + + const plaintext = decrypt(raw, sharedKey) + if (plaintext === null) { + return + } + + let response: RpcResponse + try { + response = JSON.parse(plaintext) + } catch { + return + } + + // Why: auth failure is distinct from transient disconnect — retrying + // with a rejected token causes infinite reconnect churn. + if (!response.ok && response.error.code === 'unauthorized') { + intentionallyClosed = true + ws?.close() + ws = null + setState('auth-failed') + rejectAllPending('Unauthorized — pairing may be revoked') + return + } + + const isStreaming = response.ok && (response as RpcSuccess).streaming === true + + if (isStreaming) { + const stream = streamListeners.get(response.id) + if (stream && response.ok) { + stream.listener((response as RpcSuccess).result) + } + return + } + + if (response.ok) { + const result = (response as RpcSuccess).result as Record | null + if (result && result.type === 'end') { + const stream = streamListeners.get(response.id) + if (stream) { + stream.listener(result) + streamListeners.delete(response.id) + return + } + } + if (result && result.type === 'scrollback') { + const stream = streamListeners.get(response.id) + if (stream) { + stream.listener(result) + return + } + } + } + + const req = pending.get(response.id) + if (req) { + pending.delete(response.id) + req.resolve(response) + } + } + + ws.onclose = (event) => { + const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined + const closeAt = Date.now() + // Why: time-since-construct distinguishes failure modes. Instant + // close (<300ms) = TCP RST / port closed / route unreachable / RN + // synchronous reject. Mid (300ms–3s) = DNS/connect attempt + reset. + // Slow (>3s) = TCP SYN timeout / packet loss / NAT wedge. If an + // entire reconnect burst is all instant, the problem is local + // process state or routing, not packet loss. + const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null + const aliveMs = + currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null + const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null + // Why: inline the diagnostic dump. Earlier hot-reload tripped + // `Property 'enumKeys' doesn't exist` because a stale closure + // captured a half-loaded module. Inlining keeps the handler's + // behavior fully decided at construction time. + let closeEventKeys: string[] = [] + let closeEventStr = '' + try { + closeEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : [] + } catch { + closeEventKeys = [] + } + try { + const seen = new WeakSet() + closeEventStr = JSON.stringify( + event, + (_k, v) => { + if (typeof v === 'object' && v !== null) { + if (seen.has(v as object)) return '[circular]' + seen.add(v as object) + } + if (typeof v === 'function') return '[fn]' + return v + }, + 0 + ).slice(0, 500) + } catch { + closeEventStr = '[unstringifiable]' + } + console.log('[net] ws.onclose', { + code: e?.code, + reason: e?.reason, + wasClean: e?.wasClean, + state, + attempt: reconnectAttempt, + intentionallyClosed, + endpoint: redactedEndpoint(endpoint), + constructToCloseMs, + aliveMs, + inboundIdleMs, + eventKeys: closeEventKeys, + eventStr: closeEventStr + }) + lastWsClosedAt = closeAt + currentWsOpenedAt = null + handleSocketClosed(openingWs) + } + + ws.onerror = (event) => { + // Why: RN surfaces network errors here (DNS failure, TCP RST, etc). + // onclose fires right after, but logging the error message gives us + // the original cause that the close code alone can hide. + const e = event as { message?: string } | undefined + // Why: inlined defensively — see ws.onclose comment. + let errEventKeys: string[] = [] + let errEventStr = '' + try { + errEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : [] + } catch { + errEventKeys = [] + } + try { + const seen = new WeakSet() + errEventStr = JSON.stringify( + event, + (_k, v) => { + if (typeof v === 'object' && v !== null) { + if (seen.has(v as object)) return '[circular]' + seen.add(v as object) + } + if (typeof v === 'function') return '[fn]' + return v + }, + 0 + ).slice(0, 500) + } catch { + errEventStr = '[unstringifiable]' + } + console.log('[net] ws.onerror', { + message: e?.message, + state, + attempt: reconnectAttempt, + eventKeys: errEventKeys, + eventStr: errEventStr + }) + } + } + + function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) { + if (ws !== closedWs) { + console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', { + state, + attempt: reconnectAttempt + }) + return + } + clearConnectTimer() + ws = null + sharedKey = null + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + stopActivityProbe() + if (intentionallyClosed) { + console.log('[net] handleSocketClosed — intentional close') + setState('disconnected') + rejectAllPending('Connection closed') + return + } + console.log('[net] handleSocketClosed → reconnect', { + timedOut: !!opts.timedOut, + pendingCount: pending.size, + streamCount: streamListeners.size, + attempt: reconnectAttempt + }) + emitLog('warn', 'WebSocket closed', 'Will attempt to reconnect') + rejectAllPending('Connection interrupted') + setState('reconnecting') + scheduleReconnect() + } + + function scheduleReconnect() { + // Why: spinning reconnect forever drains battery and floods logs + // when the host is genuinely unreachable (wrong IP, port closed, + // host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a + // "Can't reach desktop, re-pair?" banner at this point and the + // user can tap Retry (forceReconnect creates a fresh client, + // resetting the counter) or Re-pair. Without an explicit cap the + // worst-case is a phone left on the home screen burning a socket + // open every 4s indefinitely. + if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { + console.log('[net] reconnect-paused', { + attempt: reconnectAttempt, + reason: 'give-up-cap', + endpoint: redactedEndpoint(endpoint) + }) + return + } + const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! + reconnectAttempt++ + console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt }) + emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`) + reconnectTimer = setTimeout(() => { + reconnectTimer = null + openConnection() + }, delay) + } + + function clearConnectTimer() { + if (connectTimer) { + clearTimeout(connectTimer) + connectTimer = null + } + } + + // Why: app-level liveness probe — see ACTIVITY_PROBE_INTERVAL_MS comment + // at the top of the file. Fires while the channel is in 'connected' + // state, sends a tiny status.get, and force-closes the WS if the probe + // fails (which the existing onclose path then turns into a reconnect). + function startActivityProbe() { + stopActivityProbe() + activityProbeTimer = setInterval(() => { + // Why: only probe while the channel is actually in 'connected'. The + // sendRequest path itself waits for connected, but a probe scheduled + // during a reconnect would just stack up timeouts and confuse logs. + if (state !== 'connected' || !ws) return + const probeWs = ws + // Why: short timeout (8s) — server's heartbeat is 15s, so if we + // don't see *anything* back within 8s the link is almost certainly + // half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the + // user wait nearly a minute before reconnect kicks in. + const id = nextId() + const probeStart = Date.now() + let timedOut = false + const timeout = setTimeout(() => { + timedOut = true + pending.delete(id) + console.log('[net] activity-probe TIMEOUT — forcing reconnect', { + waitedMs: Date.now() - probeStart, + state + }) + // Why: only force-close if this is still the same socket the + // probe was sent on; a normal close that already swapped `ws` + // shouldn't trigger a redundant terminate. + if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) { + probeWs.close() + } + }, 8_000) + pending.set(id, { + resolve: () => { + if (timedOut) return + clearTimeout(timeout) + }, + reject: () => { + if (timedOut) return + clearTimeout(timeout) + } + }) + if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) { + clearTimeout(timeout) + pending.delete(id) + } + }, ACTIVITY_PROBE_INTERVAL_MS) + } + + function stopActivityProbe() { + if (activityProbeTimer) { + clearInterval(activityProbeTimer) + activityProbeTimer = null + } + } + + function rejectAllPending(reason: string) { + const error = new Error(reason) + for (const [id, req] of pending) { + pending.delete(id) + queueMicrotask(() => req.reject(error)) + } + } + + function sendEncrypted(request: unknown): boolean { + if (ws && ws.readyState === WebSocket.OPEN && sharedKey) { + ws.send(encrypt(JSON.stringify(request), sharedKey)) + return true + } + console.log('[net] sendEncrypted FAILED — channel not ready', { + hasWs: !!ws, + readyState: ws?.readyState, + hasKey: !!sharedKey, + state + }) + // Why: if the state machine still thinks we're connected but the + // underlying WebSocket has flipped to CLOSING/CLOSED without onclose + // having fired (RN's WebSocket sometimes drops the event, or the + // server half-closed the stream), force a reconnect. Without this + // every send silently fails forever and the user sees a frozen UI. + if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) { + console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { + readyState: ws.readyState + }) + handleSocketClosed(ws, { timedOut: false }) + } + return false + } + + openConnection() + + return { + async sendRequest(method: string, params?: unknown): Promise { + const waitStart = Date.now() + const wasConnected = state === 'connected' + await waitForConnected() + if (!wasConnected) { + console.log('[net] sendRequest waited for connect', { + method, + waitedMs: Date.now() - waitStart + }) + } + + return new Promise((resolve, reject) => { + const id = nextId() + const timeout = setTimeout(() => { + pending.delete(id) + console.log('[net] sendRequest TIMEOUT', { + method, + timeoutMs: REQUEST_TIMEOUT_MS, + state + }) + reject(new Error(`Request timed out: ${method}`)) + }, REQUEST_TIMEOUT_MS) + + pending.set(id, { + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + + if (!sendEncrypted({ id, deviceToken, method, params })) { + pending.delete(id) + clearTimeout(timeout) + reject(new Error('Connection interrupted')) + } + }) + }, + + subscribe(method: string, params: unknown, onData: StreamingListener): () => void { + const id = nextId() + streamListeners.set(id, { method, params, listener: onData }) + + if (state === 'connected') { + sendEncrypted({ id, deviceToken, method, params }) + } else { + // Stream is registered but the actual outbound subscribe will be + // sent (or re-sent) when the channel reaches 'connected'. Useful + // when terminals don't load — confirms the request is queued. + console.log('[net] subscribe queued — waiting for connected', { method, state }) + } + + return () => { + const stream = streamListeners.get(id) + streamListeners.delete(id) + if ( + stream?.method === 'terminal.subscribe' && + stream.params && + typeof stream.params === 'object' && + typeof (stream.params as { terminal?: unknown }).terminal === 'string' + ) { + // Why: the runtime registers cleanup under the composite key + // `${terminal}:${clientId}` so two phones subscribing to the same + // terminal handle don't evict each other. Echo that composite key + // back on unsubscribe; also include `client.id` so the server can + // reconstruct it if a stale build emits a bare-handle id. See + // docs/mobile-presence-lock.md. + const subscribeParams = stream.params as { + terminal: string + client?: { id?: string } + } + const clientId = + typeof subscribeParams.client?.id === 'string' ? subscribeParams.client.id : undefined + const subscriptionId = clientId + ? `${subscribeParams.terminal}:${clientId}` + : subscribeParams.terminal + sendEncrypted({ + id: nextId(), + deviceToken, + method: 'terminal.unsubscribe', + params: { + subscriptionId, + ...(clientId ? { client: { id: clientId } } : {}) + } + }) + } + } + }, + + getState(): ConnectionState { + return state + }, + + getReconnectAttempt(): number { + return reconnectAttempt + }, + + getLastConnectedAt(): number | null { + return lastConnectedAt + }, + + onStateChange(listener: (state: ConnectionState) => void): () => void { + stateListeners.add(listener) + return () => stateListeners.delete(listener) + }, + + close() { + intentionallyClosed = true + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + clearConnectTimer() + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + stopActivityProbe() + if (ws) { + ws.close() + ws = null + } + sharedKey = null + setState('disconnected') + rejectAllPending('Client closed') + } + } +} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts new file mode 100644 index 00000000000..6750dc69eca --- /dev/null +++ b/mobile/src/transport/types.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' + +export type RpcRequest = { + id: string + deviceToken: string + method: string + params?: unknown +} + +export type RpcSuccess = { + id: string + ok: true + result: unknown + streaming?: true + _meta: { runtimeId: string } +} + +export type RpcFailure = { + id: string + ok: false + error: { code: string; message: string; data?: unknown } + _meta: { runtimeId: string } +} + +export type RpcResponse = RpcSuccess | RpcFailure + +export const PAIRING_OFFER_VERSION = 2 + +export const PairingOfferSchema = z.object({ + v: z.literal(PAIRING_OFFER_VERSION), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1) +}) + +export type PairingOffer = z.infer + +export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error' + +export type ConnectionLogEntry = { + id: string + ts: number + level: ConnectionLogLevel + // Short human-readable phase label, e.g. 'Opening WebSocket'. + message: string + // Optional second line for endpoint/error/elapsed detail. + detail?: string +} + +export type ConnectionLogSink = (entry: ConnectionLogEntry) => void + +export type ConnectionState = + | 'connecting' + | 'handshaking' + | 'connected' + | 'disconnected' + | 'reconnecting' + | 'auth-failed' + +export type HostProfile = { + id: string + name: string + endpoint: string + deviceToken: string + publicKeyB64: string + lastConnected: number +} + +export const HostProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1), + lastConnected: z.number().finite() +}) + +// Why: persisted host record after the v0.0.3 keychain split. The +// deviceToken is held in iOS Keychain via expo-secure-store and joined +// in at load time; it must NOT appear in AsyncStorage anymore. +export const StoredHostProfileSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + publicKeyB64: z.string().min(1), + lastConnected: z.number().finite() +}) + +export type StoredHostProfile = z.infer diff --git a/mobile/terminal-output-streaming-findings.md b/mobile/terminal-output-streaming-findings.md new file mode 100644 index 00000000000..5f9aac2b23a --- /dev/null +++ b/mobile/terminal-output-streaming-findings.md @@ -0,0 +1,56 @@ +# Mobile Terminal Output Streaming Findings + +Date: 2026-04-28 + +## Scope + +The investigation was limited to `mobile/`. Server-side files under `src/main/` were read for context only and were not modified. + +## Verified Findings + +- The phone can connect to `ws://192.168.0.179:6768`, list worktrees, list terminals, and call `terminal.send`. +- The phone's saved host token is valid; a direct WebSocket probe from the desktop using the same token can call `worktree.ps`, `terminal.list`, `terminal.subscribe`, `terminal.send`, and `terminal.read`. +- `TerminalWebView` can render text when the React Native side writes to it after xterm initializes. A temporary marker written after `init()` appeared visibly in the WebView. +- Messages posted to the WebView before its page installs message handlers can be dropped. The mobile fix queues `init`, `write`, and `clear` until the WebView reports `web-ready`. +- The WebView also has an internal queue for writes that arrive after `web-ready` but before xterm finishes `init()`. +- The selected physical-phone test worktree was `refs/heads/tasks-improvements` at `/Users/jinwoohong/orca/workspaces/orca/pr-1172-review`. +- For that test worktree, `terminal.subscribe` produced an initial `scrollback` event with an empty `lines` array and no serialized buffer. +- Sending commands to that test terminal returned `ok:true`, but a direct WebSocket `terminal.read` for the same handle still returned an empty tail and no live `data` chunks were observed. +- Creating a fresh terminal with `terminal.create` in that same worktree also returned a writable handle, but `terminal.send` followed by delayed `terminal.read` still returned an empty tail. +- Adding a mobile-side `terminal.read` fallback did not make the current physical-phone test terminal display output, because the direct WebSocket `terminal.read` response for `mobile-output-test` remained `tail: []` and `lastOutputAt: null` after `echo hi`. +- Calling `terminal.show` before `terminal.send` on `mobile-output-test` returned a connected/writable terminal with a `ptyId`, but a delayed `terminal.read` still returned an empty tail. +- Root cause found after building a no-phone repro: daemon-backed PTYs were forwarding provider data to the desktop renderer, but not into `runtime.onPtyData`. The runtime tail buffer and `terminal.subscribe` listeners are fed by `runtime.onPtyData`, so the phone saw accepted sends with no output. +- Fix: `src/main/ipc/pty.ts` now forwards `provider.onData` into `runtime.onPtyData` for non-`LocalPtyProvider` providers. Local PTYs already use the LocalPtyProvider configure hook, so the guard avoids duplicate local output. +- After restarting Electron, `pnpm exec tsx mobile/scripts/test-subscribe.ts ` passed: both `streamSawMarker` and `readSawMarker` were `true`. +- After the backend fix, the physical phone rendered the repro marker in `TerminalWebView`. +- Because of the empty tail/no live stream on that specific desktop terminal, the physical `ls` test did not prove the WebView display path after real PTY output. + +## Changes Made + +- `mobile/src/terminal/TerminalWebView.tsx` + - Added a native-side queue so WebView messages are not sent until the HTML reports `web-ready`. + - Added a WebView-side queue so writes wait until xterm finishes `init()`. +- `mobile/src/transport/rpc-client.ts` + - Stored full stream request metadata so active streams can be replayed after reconnect. + - Avoided sending a subscription before the socket reaches `connected`. + - Fixed `terminal.unsubscribe` to use the terminal handle as `subscriptionId`, matching runtime cleanup behavior. +- `mobile/app/h/[hostId]/session/[worktreeId].tsx` + - Clears and resubscribes when the worktree changes. + - Tracks the active terminal handle in a ref so stale handles are replaced when `terminal.list` changes. + - Routes scrollback and live data into `TerminalWebView`. + +## Useful Commands + +```bash +agent-device devices --json +agent-device snapshot --platform android --serial R3CX105QXRH --json +agent-device fill @e33 "ls" --platform android --serial R3CX105QXRH --json +agent-device click @e34 --platform android --serial R3CX105QXRH --json +agent-device screenshot /tmp/mobile-terminal.png --platform android --serial R3CX105QXRH --json +orca terminal send --terminal term_926b8898-f843-461a-acd2-482f741327ad --text r --json +orca terminal read --terminal term_926b8898-f843-461a-acd2-482f741327ad --json +``` + +## Next Debug Boundary + +Do not repeat WebView readiness tests unless the WebView changes again; the WebView can render post-init writes. For future regressions, run the no-phone repro first. If it fails, debug the WebSocket/runtime/PTY bridge before touching the mobile UI. If it passes but the phone is blank, debug `TerminalWebView` or session-screen routing. diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 00000000000..6aea44464c3 --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["**/*.test.ts", "**/*.test.tsx"] +} diff --git a/native/computer-use-linux/runtime.py b/native/computer-use-linux/runtime.py new file mode 100644 index 00000000000..ac6495ac50e --- /dev/null +++ b/native/computer-use-linux/runtime.py @@ -0,0 +1,819 @@ +#!/usr/bin/env python3 +"""Orca Linux computer-use bridge. + +The Node sidecar owns Orca's public API. This process is intentionally a small +AT-SPI adapter: read one JSON operation file, execute it in the user's desktop +session, and print one JSON response. +""" + +import base64 +import json +import math +import os +import re +import shutil +import subprocess +import sys +import time +import uuid +from dataclasses import dataclass + +import gi + +gi.require_version("Atspi", "2.0") +try: + gi.require_version("Gdk", "3.0") + from gi.repository import Gdk +except (ImportError, ValueError): + Gdk = None +from gi.repository import Atspi + +MAX_NODES = 1200 +MAX_DEPTH = 64 +TEXT_LIMIT = 500 +BLOCKED_APP_FRAGMENTS = ( + "1password", + "bitwarden", + "dashlane", + "lastpass", + "nordpass", + "proton pass", +) + + +@dataclass +class Rect: + x: float + y: float + width: float + height: float + + def to_json(self): + return {"x": self.x, "y": self.y, "width": self.width, "height": self.height} + + +def attempt(fn, fallback=None): + try: + value = fn() + return fallback if value is None else value + except Exception: + return fallback + + +def ensure_desktop_bus(): + missing = [name for name in ("XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS") if not os.environ.get(name)] + if missing: + raise RuntimeError("Linux computer use requires an active desktop session; missing " + ", ".join(missing)) + + +def desktop_root(): + return Atspi.get_desktop(0) + + +def children(node): + count = int(attempt(node.get_child_count, 0) or 0) + for index in range(count): + child = attempt(lambda i=index: node.get_child_at_index(i)) + if child is not None: + yield index, child + + +def text_attr(node, getter): + return str(attempt(getter, "") or "") + + +def name_of(node): + return text_attr(node, node.get_name) + + +def role_of(node): + return text_attr(node, node.get_role_name) + + +def pid_of(node): + return int(attempt(node.get_process_id, 0) or 0) + + +def has_state(node, state): + state_set = attempt(node.get_state_set) + return bool(state_set is not None and attempt(lambda: state_set.contains(state), False)) + + +def screen_rect(node): + component = attempt(node.get_component_iface) + if component is None: + return None + rect = attempt(lambda: Atspi.Component.get_extents(component, Atspi.CoordType.SCREEN)) + if rect is None or rect.width <= 0 or rect.height <= 0: + return None + return Rect(float(rect.x), float(rect.y), float(rect.width), float(rect.height)) + + +def relative_rect(node, window_rect): + rect = screen_rect(node) + if rect is None or window_rect is None: + return rect + return Rect(rect.x - window_rect.x, rect.y - window_rect.y, rect.width, rect.height) + + +def desktop_apps(): + for _, app in children(desktop_root()): + if name_of(app): + yield app + + +def windows_for(app): + result = [] + for index, child in children(app): + role = role_of(child).lower() + rect = screen_rect(child) + if rect is not None or role in {"frame", "window", "dialog", "alert"}: + result.append((index, child)) + return result + + +def choose_window(app, window_id=None, window_index=None): + windows = windows_for(app) + if not windows: + raise RuntimeError("No top-level AT-SPI window is available for " + name_of(app)) + target = window_id if window_id is not None else window_index + if target is not None: + for item in windows: + if item[0] == int(target): + return item + raise RuntimeError(f'windowNotFound("{target}")') + for item in windows: + if has_state(item[1], Atspi.StateType.ACTIVE): + return item + for item in windows: + if has_state(item[1], Atspi.StateType.SHOWING): + return item + return windows[0] + + +def restore_window(app): + pid = pid_of(app) + if not pid or not shutil.which("xdotool"): + return + subprocess.run( + ["xdotool", "search", "--pid", str(pid), "windowactivate", "--sync"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def require_keyboard_focus(window, operation): + if operation.get("restoreWindow") or has_state(window, Atspi.StateType.ACTIVE): + return + raise RuntimeError("window_not_focused: keyboard input requires the target window to be focused; retry with --restore-window") + + +def app_matches(app, query): + needle = str(query or "").strip().lower() + if not needle: + return False + if needle.startswith("pid:"): + return str(pid_of(app)) == needle[4:] + if needle.isdigit() and pid_of(app) == int(needle): + return True + haystacks = [name_of(app).lower()] + [name_of(window).lower() for _, window in windows_for(app)] + return any(value == needle or needle in value for value in haystacks) + + +def find_app(query): + for app in desktop_apps(): + if app_matches(app, query): + reject_blocked_app(app) + return app + raise RuntimeError(f'appNotFound("{query}")') + + +def reject_blocked_app(app): + haystacks = [name_of(app).lower()] + [name_of(window).lower() for _, window in windows_for(app)] + if any(fragment in value for fragment in BLOCKED_APP_FRAGMENTS for value in haystacks): + raise RuntimeError(f'appBlocked("{name_of(app)}")') + + +def action_labels(node): + labels = [] + count = int(attempt(node.get_n_actions, 0) or 0) + for index in range(count): + label = str(attempt(lambda i=index: node.get_action_name(i), "") or "") + description = str(attempt(lambda i=index: node.get_action_description(i), "") or "") + value = label or description + if value and value not in labels: + labels.append(value) + return labels + + +def meaningful_actions(actions): + noisy = { + "click", + "press", + "show default ui", + "show alternate ui", + "show menu", + "scroll to visible", + "raise", + } + return [action for action in actions if action.strip().lower() not in noisy] + + +def display_action(action): + value = str(action or "").strip() + if not value: + return value + return " ".join(value.replace("_", " ").split()) + + +def sanitize_text(value): + return " ".join(str(value or "").replace("\r", " ").replace("\n", " ").split()) + + +def formatted_value(role_key, title, value): + clean = sanitize_text(value) + if not clean or clean == title: + return "" + if role_key in {"label", "static", "static text", "text", "entry", "text entry"}: + return " " + clean + return ", Value: " + clean + + +def suppress_children(role_key, title, value, summary): + has_compact_label = bool(title or sanitize_text(value) or sanitize_text(summary)) + return has_compact_label and role_key in { + "button", + "check box", + "checkbox", + "combo box", + "heading", + "link", + "menu item", + "page tab", + "push button", + "radio button", + } + + +def string_value(node): + if is_secure_node(node): + return "[redacted]" + if bool(attempt(node.is_text, False)): + iface = attempt(node.get_text_iface) + count = int(attempt(lambda: Atspi.Text.get_character_count(iface), 0) or 0) + if iface is not None and count > 0: + value = str(attempt(lambda: Atspi.Text.get_text(iface, 0, min(count, TEXT_LIMIT)), "") or "") + return value + ("..." if count > TEXT_LIMIT else "") + value_iface = attempt(node.get_value_iface) + if value_iface is not None: + current = attempt(lambda: Atspi.Value.get_current_value(value_iface)) + if current is not None: + return str(current) + return "" + + +def is_secure_node(node): + role = role_of(node).lower() + label = " ".join([role, name_of(node).lower(), accessible_id(node).lower()]) + if any(term in label for term in ("password", "passcode", "pin", "secret", "one-time code")): + return True + state_set = attempt(node.get_state_set) + protected_state = getattr(Atspi.StateType, "PROTECTED", None) + if state_set is not None and protected_state is not None and attempt(lambda: state_set.contains(protected_state), False): + return True + return False + + +def accessible_id(node): + return str(attempt(node.get_accessible_id, "") or "") + + +def record(node, index, path, window_rect): + role = role_of(node) + rect = relative_rect(node, window_rect) + return { + "index": index, + "runtimeId": path, + "automationId": accessible_id(node), + "name": name_of(node), + "controlType": role, + "localizedControlType": role, + "className": str(attempt(node.get_toolkit_name, "") or ""), + "value": string_value(node), + "nativeWindowHandle": 0, + "frame": rect.to_json() if rect else None, + "actions": action_labels(node), + } + + +def render_accessibility_tree(root, window_rect, root_path): + records = [] + lines = [] + truncation = {"truncated": False, "maxNodes": MAX_NODES, "maxDepth": MAX_DEPTH, "maxDepthReached": False} + + def text_snippets(node, limit=6, max_depth=3): + values = [] + seen = set() + + def collect(candidate, depth): + if len(values) >= limit or depth > max_depth: + return + role = role_of(candidate).lower() + if role in {"label", "static", "static text", "text", "link"}: + for raw in (name_of(candidate), string_value(candidate)): + value = " ".join(str(raw or "").split()) + if value and value not in seen: + seen.add(value) + values.append(value[:80]) + if len(values) >= limit: + return + for _, child in children(candidate): + collect(child, depth + 1) + if len(values) >= limit: + return + + collect(node, 0) + return values + + def is_plain_text_subtree(node, max_depth=4): + saw_text = False + allowed = {"panel", "filler", "unknown", "section", "label", "static", "static text", "text", "link", "image"} + + def visit(candidate, depth): + nonlocal saw_text + if depth > max_depth: + return False + role = role_of(candidate).lower() + if role not in allowed: + return False + if role in {"label", "static", "static text", "text", "link"}: + saw_text = True + if meaningful_actions(action_labels(candidate)): + return False + return all(visit(child, depth + 1) for _, child in children(candidate)) + + return visit(node, 0) and saw_text + + def should_elide(item, child_count, summary): + role = (item["controlType"] or "").lower() + has_text = bool(item["name"] or item["automationId"] or item["value"]) + return role in {"panel", "filler", "unknown", "section"} and not has_text and not meaningful_actions(item["actions"]) and summary is None and child_count <= 1 + + def walk(node, depth, path): + if len(records) >= MAX_NODES or depth > MAX_DEPTH: + truncation["truncated"] = True + if depth > MAX_DEPTH: + truncation["maxDepthReached"] = True + return + item = record(node, len(records), path, window_rect) + child_items = list(children(node)) + role_key = (item["controlType"] or "").lower() + summary_values = text_snippets(node, limit=8, max_depth=4) + generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None + if should_elide(item, len(child_items), generic_summary): + for child_index, child in child_items: + walk(child, depth, path + [child_index]) + return + records.append(item) + title = item["name"] or item["automationId"] or "" + role_label = item["localizedControlType"] or item["controlType"] + line = f'{item["index"]} {role_label} {sanitize_text(title)}'.rstrip() + line += formatted_value(role_key, title, item["value"]) + if generic_summary and generic_summary != title: + line += ", Text: " + sanitize_text(generic_summary) + elif role_key in {"row", "table row", "list item"}: + row_summary = " ".join(text_snippets(node)) + if row_summary and row_summary != title: + line += ", Text: " + sanitize_text(row_summary) + filtered_actions = meaningful_actions(item["actions"]) + if filtered_actions: + line += ", Secondary Actions: " + ", ".join(display_action(action) for action in filtered_actions) + lines.append(("\t" * depth) + line) + if generic_summary or suppress_children(role_key, title, item["value"], generic_summary): + return + for child_index, child in child_items: + walk(child, depth + 1, path + [child_index]) + + walk(root, 0, root_path) + return records, lines, truncation + + +def capture_png(rect): + if Gdk is None or rect is None or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland": + return None + screen = Gdk.Screen.get_default() + root = screen.get_root_window() if screen else None + if root is None: + return None + pixbuf = Gdk.pixbuf_get_from_window(root, round(rect.x), round(rect.y), max(1, round(rect.width)), max(1, round(rect.height))) + if pixbuf is None: + return None + ok, data = pixbuf.save_to_bufferv("png", [], []) + return base64.b64encode(bytes(data)).decode("ascii") if ok else None + + +def first_descendant(root, predicate): + if predicate(root): + return root + for _, child in children(root): + found = first_descendant(child, predicate) + if found is not None: + return found + return None + + +def focused_summary(window): + node = first_descendant(window, lambda candidate: has_state(candidate, Atspi.StateType.FOCUSED)) + if node is None: + return None + return (role_of(node) + " " + name_of(node)).strip() + + +def selected_text(window): + node = first_descendant(window, lambda candidate: has_state(candidate, Atspi.StateType.FOCUSED) and bool(attempt(candidate.is_text, False))) + iface = attempt(node.get_text_iface) if node is not None else None + selections = attempt(lambda: Atspi.Text.get_text_selections(iface), []) + if not selections: + return None + selection = selections[0] + return Atspi.Text.get_text(iface, selection.start_offset, selection.end_offset) + + +def app_json(app): + return {"name": name_of(app), "bundleIdentifier": name_of(app), "pid": pid_of(app)} + + +def window_json(app, index, window): + bounds = screen_rect(window) + showing = has_state(window, Atspi.StateType.SHOWING) + return { + "index": index, + "app": app_json(app), + "id": index, + "title": name_of(window), + "x": round(bounds.x) if bounds else None, + "y": round(bounds.y) if bounds else None, + "width": max(0, round(bounds.width if bounds else 0)), + "height": max(0, round(bounds.height if bounds else 0)), + "isMinimized": not showing, + "isOffscreen": not showing, + "screenIndex": None, + "platform": {"backend": "at-spi", "runtimeId": [index], "role": role_of(window)}, + } + + +def make_snapshot(query, include_screenshot, window_id=None, window_index=None, restore=False): + app = find_app(query) + if restore: + restore_window(app) + window_index, window = choose_window(app, window_id, window_index) + bounds = screen_rect(window) + records, lines, truncation = render_accessibility_tree(window, bounds, [window_index]) + return { + "snapshotId": str(uuid.uuid4()), + "app": app_json(app), + "windowTitle": name_of(window), + "windowId": window_index, + "windowBounds": bounds.to_json() if bounds else None, + "screenshotPngBase64": capture_png(bounds) if include_screenshot else None, + "coordinateSpace": "window", + "truncation": truncation, + "treeLines": lines, + "focusedSummary": focused_summary(window), + "selectedText": selected_text(window), + "elements": records, + } + + +def list_apps_response(): + apps = [] + for app in sorted(desktop_apps(), key=lambda value: (name_of(value).lower(), pid_of(value))): + if windows_for(app): + apps.append({"name": name_of(app), "bundleIdentifier": name_of(app), "pid": pid_of(app)}) + return apps + + +def list_windows_response(query): + app = find_app(query) + return {"app": app_json(app), "windows": [window_json(app, index, window) for index, window in windows_for(app)]} + + +def handshake_response(): + is_wayland = os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland" + has_hotkey = shutil.which("xdotool") is not None and not is_wayland + has_clipboard = any(shutil.which(command) for command in ("wl-copy", "xclip", "xsel")) + has_screenshot = Gdk is not None and not is_wayland + return { + "platform": "linux", + "provider": "orca-computer-use-linux", + "providerVersion": "1.0.0", + "protocolVersion": 1, + "supports": { + "apps": {"list": True, "bundleIds": False, "pids": True}, + "windows": {"list": True, "targetById": True, "targetByIndex": True, "focus": False, "moveResize": False}, + "observation": {"screenshot": has_screenshot, "annotatedScreenshot": False, "elementFrames": True, "ocr": False}, + "actions": { + "click": True, + "typeText": True, + "pressKey": True, + "hotkey": has_hotkey, + "pasteText": has_clipboard, + "scroll": True, + "drag": True, + "setValue": True, + "performAction": True, + }, + "surfaces": {"menus": False, "dialogs": False, "dock": False, "menubar": False}, + }, + } + + +def find_by_path(app, path): + node = app + for index in path or []: + node = dict(children(node)).get(int(index)) + if node is None: + return None + return node + + +def all_nodes(root): + result = [] + + def walk(node): + if len(result) >= MAX_NODES: + return + result.append(node) + for _, child in children(node): + walk(child) + + walk(root) + return result + + +def find_element(app, saved): + if not saved: + return None + by_path = find_by_path(app, saved.get("runtimeId")) + if by_path is not None and same_element_signature(by_path, saved): + return by_path + return None + + +def same_element_signature(node, saved): + if role_of(node) != str(saved.get("controlType") or ""): + return False + if name_of(node) != str(saved.get("name") or ""): + return False + if accessible_id(node) != str(saved.get("automationId") or ""): + return False + saved_actions = [str(action) for action in saved.get("actions") or []] + return action_labels(node) == saved_actions + + +def preferred_action(node): + if node is None: + return None + priority = {"click", "press", "activate", "invoke", "select", "toggle", "open"} + fallback = None + for index in range(int(attempt(node.get_n_actions, 0) or 0)): + label = str(attempt(lambda i=index: node.get_action_name(i), "") or "").lower() + if label in priority: + return index + if fallback is None and any(term in label for term in ("click", "press", "activate")): + fallback = index + return fallback + + +def perform_action(node, index): + return bool(index is not None and attempt(lambda: node.do_action(int(index)), False)) + + +def screen_point(window_rect, saved_element=None, x=None, y=None, node=None): + rect = screen_rect(node) if node is not None else None + if rect is not None: + return rect.x + rect.width / 2, rect.y + rect.height / 2 + if saved_element is not None: + raise RuntimeError("stale element frame; run get-app-state again and use a fresh element index") + if window_rect is None or x is None or y is None: + raise RuntimeError("coordinate action requires a visible window and coordinates") + return window_rect.x + float(x), window_rect.y + float(y) + + +def click_at(x, y, button, count): + button = (button or "left").lower() + down, up = {"right": ("b3p", "b3r"), "middle": ("b2p", "b2r")}.get(button, ("b1p", "b1r")) + for _ in range(max(1, int(count or 1))): + Atspi.generate_mouse_event(round(x), round(y), "abs") + Atspi.generate_mouse_event(round(x), round(y), down) + time.sleep(0.03) + Atspi.generate_mouse_event(round(x), round(y), up) + + +def scroll_at(x, y, direction, pages): + down, up = { + "up": ("b4p", "b4r"), + "down": ("b5p", "b5r"), + "left": ("b6p", "b6r"), + "right": ("b7p", "b7r"), + }.get(str(direction or "down").lower(), ("b5p", "b5r")) + for _ in range(max(1, math.ceil(float(pages or 1)))): + Atspi.generate_mouse_event(round(x), round(y), "abs") + Atspi.generate_mouse_event(round(x), round(y), down) + time.sleep(0.03) + Atspi.generate_mouse_event(round(x), round(y), up) + + +def drag_between(start, end): + Atspi.generate_mouse_event(round(start[0]), round(start[1]), "abs") + Atspi.generate_mouse_event(round(start[0]), round(start[1]), "b1p") + for step in range(1, 13): + x = start[0] + (end[0] - start[0]) * step / 12 + y = start[1] + (end[1] - start[1]) * step / 12 + Atspi.generate_mouse_event(round(x), round(y), "abs") + time.sleep(0.02) + Atspi.generate_mouse_event(round(end[0]), round(end[1]), "b1r") + + +def key_name(raw): + aliases = { + "return": "Return", "enter": "Return", "tab": "Tab", "escape": "Escape", "esc": "Escape", + "backspace": "BackSpace", "delete": "Delete", "space": "space", "left": "Left", "right": "Right", + "up": "Up", "down": "Down", "page_up": "Page_Up", "page_down": "Page_Down", + } + return aliases.get(str(raw).lower(), str(raw)) + + +def press_key(raw): + name = key_name(raw) + if len(name) == 1: + Atspi.generate_keyboard_event(0, name, Atspi.KeySynthType.STRING) + return + if Gdk is None: + raise RuntimeError("GDK is required for non-character key synthesis") + Atspi.generate_keyboard_event(Gdk.keyval_from_name(name), None, Atspi.KeySynthType.PRESSRELEASE) + + +def hotkey(raw): + key_spec = re.sub(r"(?i)commandorcontrol|cmdorctrl", "ctrl", str(raw)) + xdotool = shutil.which("xdotool") + if xdotool: + subprocess.run([xdotool, "key", key_spec], check=True) + return + if "+" in key_spec: + raise RuntimeError("hotkey combinations require xdotool") + press_key(key_spec) + + +def type_text(value): + Atspi.generate_keyboard_event(0, str(value), Atspi.KeySynthType.STRING) + + +def paste_text(value): + text = str(value) + previous = read_clipboard() + try: + write_clipboard(text) + hotkey("ctrl+v") + finally: + if previous is not None: + write_clipboard(previous) + + +def read_clipboard(): + for command in (["wl-paste"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"]): + if shutil.which(command[0]): + result = subprocess.run(command, check=False, capture_output=True, text=True) + if result.returncode == 0: + return result.stdout + return None + + +def write_clipboard(value): + for command in (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]): + if shutil.which(command[0]): + subprocess.run(command, input=value, check=True, text=True) + return + raise RuntimeError("paste_text requires wl-copy, xclip, or xsel") + + +def set_value(node, value): + if node is not None and bool(attempt(node.is_editable_text, False)): + editable = attempt(node.get_editable_text_iface) + if editable is not None and attempt(lambda: Atspi.EditableText.set_text_contents(editable, str(value)), False): + return True + value_iface = attempt(node.get_value_iface) if node is not None else None + if value_iface is not None: + return bool(attempt(lambda: Atspi.Value.set_current_value(value_iface, float(value)), False)) + return False + + +def run_operation(operation): + tool = operation.get("tool") + include_screenshot = not bool(operation.get("noScreenshot")) + if tool == "handshake": + return {"ok": True, "capabilities": handshake_response()} + if tool == "list_apps": + return {"ok": True, "apps": list_apps_response()} + if tool == "list_windows": + return {"ok": True, **list_windows_response(operation.get("app", ""))} + if tool == "get_app_state": + return { + "ok": True, + "snapshot": make_snapshot( + operation.get("app", ""), + include_screenshot, + operation.get("windowId"), + operation.get("windowIndex"), + bool(operation.get("restoreWindow")), + ), + } + + app = find_app(operation.get("app", "")) + if operation.get("restoreWindow"): + restore_window(app) + _, window = choose_window(app, operation.get("windowId"), operation.get("windowIndex")) + if tool in {"type_text", "press_key", "hotkey", "paste_text"}: + require_keyboard_focus(window, operation) + bounds = screen_rect(window) + saved = operation.get("element") + node = find_element(app, saved) + from_node = find_element(app, operation.get("fromElement")) + to_node = find_element(app, operation.get("toElement")) + action = None + + if tool == "click": + preferred = preferred_action(node) + click_count = int(operation.get("click_count", 1) or 1) + handled = operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred) + if not handled: + click_at(*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node), operation.get("mouse_button", "left"), operation.get("click_count", 1)) + action = {"path": "synthetic", "actionName": None, "fallbackReason": "actionUnsupported"} + else: + labels = action_labels(node) + action = {"path": "accessibility", "actionName": labels[preferred] if preferred is not None and preferred < len(labels) else "action", "fallbackReason": None} + elif tool == "perform_secondary_action": + wanted = str(operation.get("action", "")).lower() + for index, label in enumerate(action_labels(node)): + if label.lower() == wanted and perform_action(node, index): + action = {"path": "accessibility", "actionName": label, "fallbackReason": None} + break + else: + raise RuntimeError(f'{operation.get("action", "")} is not a valid secondary action') + elif tool == "scroll": + scroll_at(*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node), operation.get("direction"), operation.get("pages")) + action = {"path": "synthetic", "actionName": "scroll", "fallbackReason": None} + elif tool == "drag": + drag_between( + screen_point(bounds, operation.get("fromElement"), operation.get("from_x"), operation.get("from_y"), from_node), + screen_point(bounds, operation.get("toElement"), operation.get("to_x"), operation.get("to_y"), to_node), + ) + action = {"path": "synthetic", "actionName": "drag", "fallbackReason": None} + elif tool == "type_text": + type_text(operation.get("text", "")) + action = {"path": "synthetic", "actionName": "typeText", "fallbackReason": None} + elif tool == "press_key": + press_key(operation.get("key", "")) + action = {"path": "synthetic", "actionName": "pressKey", "fallbackReason": None} + elif tool == "hotkey": + hotkey(operation.get("key", "")) + action = {"path": "synthetic", "actionName": "hotkey", "fallbackReason": None, "verification": {"state": "unverified", "reason": "synthetic_input"}} + elif tool == "paste_text": + paste_text(operation.get("text", "")) + action = {"path": "clipboard", "actionName": "paste", "fallbackReason": None, "verification": {"state": "unverified", "reason": "clipboard_paste"}} + elif tool == "set_value": + if not set_value(node, operation.get("value", "")): + raise RuntimeError("element value is not settable") + action = {"path": "accessibility", "actionName": "setValue", "fallbackReason": None} + else: + raise RuntimeError("unknown tool: " + str(tool)) + + try: + snapshot = make_snapshot( + operation.get("app", ""), + include_screenshot, + operation.get("windowId"), + operation.get("windowIndex"), + ) + except Exception: + if operation.get("windowId") is None and operation.get("windowIndex") is None: + raise + action.setdefault("verification", {"state": "unverified", "reason": "window_changed"}) + snapshot = make_snapshot(operation.get("app", ""), include_screenshot, None, None) + + return {"ok": True, "action": action, "snapshot": snapshot} + + +def main(): + try: + ensure_desktop_bus() + with open(sys.argv[1], "r", encoding="utf-8") as handle: + operation = json.load(handle) + print(json.dumps(run_operation(operation), separators=(",", ":"))) + except Exception as exc: + print(json.dumps({"ok": False, "error": str(exc)}, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/native/computer-use-macos/Package.swift b/native/computer-use-macos/Package.swift new file mode 100644 index 00000000000..9e7da76790f --- /dev/null +++ b/native/computer-use-macos/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "OrcaComputerUseMacOS", + platforms: [ + .macOS(.v14) + ], + products: [ + .library( + name: "OrcaComputerUseMacOSCore", + targets: ["OrcaComputerUseMacOSCore"] + ), + .executable( + name: "orca-computer-use-macos", + targets: ["OrcaComputerUseMacOS"] + ) + ], + targets: [ + .target( + name: "OrcaComputerUseMacOSCore", + path: "Sources/OrcaComputerUseMacOSCore" + ), + .executableTarget( + name: "OrcaComputerUseMacOS", + dependencies: ["OrcaComputerUseMacOSCore"], + path: "Sources/OrcaComputerUseMacOS" + ), + .testTarget( + name: "OrcaComputerUseMacOSTests", + dependencies: ["OrcaComputerUseMacOSCore"], + path: "Tests/OrcaComputerUseMacOSTests" + ) + ] +) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift new file mode 100644 index 00000000000..0ed9669a2a3 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -0,0 +1,3023 @@ +import AppKit +import ApplicationServices +import CoreGraphics +import Darwin +import Foundation +import ImageIO +import OrcaComputerUseMacOSCore +import ScreenCaptureKit + +private let providerName = "orca-computer-use-macos" +private let providerVersion = "1.0.0" +private let providerProtocolVersion = 1 + +struct Request: Decodable { + let id: Int + let method: String + let params: [String: JSONValue]? + let token: String? +} + +enum JSONValue: Decodable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + var string: String? { + if case let .string(value) = self { return value } + return nil + } + + var number: Double? { + if case let .number(value) = self { return value } + return nil + } + + var bool: Bool? { + if case let .bool(value) = self { return value } + return nil + } +} + +enum ProviderError: Error { + case coded(String, String) + + var code: String { + switch self { + case let .coded(code, _): + return code + } + } + + var message: String { + switch self { + case let .coded(_, message): + return message + } + } +} + +struct AppDescriptor { + let name: String + let bundleId: String? + let pid: pid_t + let app: NSRunningApplication + + var needsManualAccessibilityMode: Bool { + // Chromium/Electron apps often need this private AX mode, but applying it + // broadly can corrupt native Cocoa app trees into app-root-only nodes. + guard let bundleId = bundleId?.lowercased() else { + return false + } + return bundleId.hasPrefix("com.google.chrome") || + bundleId.hasPrefix("com.microsoft.edgemac") || + bundleId.hasPrefix("com.brave.browser") || + bundleId.hasPrefix("com.operasoftware.opera") || + bundleId.hasPrefix("com.vivaldi.vivaldi") || + bundleId == "com.github.electron" || + bundleId == "com.tinyspeck.slackmacgap" || + bundleId == "com.spotify.client" || + bundleId == "com.hnc.discord" || + bundleId == "com.microsoft.teams2" || + bundleId == "notion.id" + } +} + +final class ElementRecord { + let index: Int + let element: AXUIElement + let localFrame: CGRect? + let actions: [String] + let signature: String + + init(index: Int, element: AXUIElement, localFrame: CGRect?, actions: [String], signature: String) { + self.index = index + self.element = element + self.localFrame = localFrame + self.actions = actions + self.signature = signature + } +} + +struct Snapshot { + let id: String + let app: AppDescriptor + let windowTitle: String + let windowBounds: CGRect + let windowId: CGWindowID + let windowLayer: Int + let treeText: String + let focusedElementId: Int? + let screenshot: ScreenshotPayload? + let screenshotStatus: ScreenshotStatus + let screenshotScale: CGSize + let screenshotEngine: String? + let elements: [Int: ElementRecord] + let truncated: Bool + let maxDepthReached: Bool +} + +struct ScreenshotPayload { + let data: String + let width: Int + let height: Int + let scale: Double +} + +struct CapturedImage { + let image: CGImage + let engine: String +} + +enum ScreenshotStatus { + case captured + case skipped + case failed(String) +} + +final class Provider { + private var snapshots: [String: Snapshot] = [:] + private var hasPromptedForAccessibility = false + + func handle(method: String, params: [String: JSONValue]) throws -> Any { + switch method { + case "handshake": + return providerHandshake() + case "listApps": + return ["apps": listApps().map(renderListedApp)] + case "listWindows": + return try listWindows(params: params) + case "getAppState": + return try renderSnapshot(observe(params: params)) + case "click": + return try actionResult(params: params) { try click(params: params) } + case "performSecondaryAction": + return try actionResult(params: params) { try performSecondaryAction(params: params) } + case "setValue": + return try actionResult(params: params) { try setValue(params: params) } + case "typeText": + return try actionResult(params: params) { try typeText(params: params) } + case "pressKey": + return try actionResult(params: params) { try pressKey(params: params) } + case "hotkey": + return try actionResult(params: params) { try hotkey(params: params) } + case "pasteText": + return try actionResult(params: params) { try pasteText(params: params) } + case "scroll": + return try actionResult(params: params) { try scroll(params: params) } + case "drag": + return try actionResult(params: params) { try drag(params: params) } + default: + throw ProviderError.coded("invalid_argument", "unknown method '\(method)'") + } + } + + private func actionResult(params: [String: JSONValue], action runAction: () throws -> [String: Any]) throws -> [String: Any] { + var action = try runAction() + do { + return try renderActionResult(action: action, snapshot: observe(params: params)) + } catch let error as ProviderError where (error.code == "window_not_found" || error.code == "window_stale") && (requestedWindowId(params) != nil || requestedWindowIndex(params) != nil) { + var fallbackParams = params + fallbackParams.removeValue(forKey: "windowId") + fallbackParams.removeValue(forKey: "windowIndex") + if action["verification"] == nil { + action["verification"] = ["state": "unverified", "reason": "window_changed"] + } + return try renderActionResult(action: action, snapshot: observe(params: fallbackParams)) + } + } + + private func observe(params: [String: JSONValue]) throws -> Snapshot { + let query = try requiredString(params, "app") + let app = try resolveApp(query) + if params["restoreWindow"]?.bool == true { + recoverWindow(app) + } + let snapshot = try buildSnapshot( + app: app, + includeScreenshot: params["noScreenshot"]?.bool != true, + windowId: requestedWindowId(params), + windowIndex: requestedWindowIndex(params), + restoreWindow: params["restoreWindow"]?.bool == true + ) + let keys = [query, app.name, app.bundleId ?? ""].filter { !$0.isEmpty }.map { $0.lowercased() } + let namespace = snapshotNamespace(params) + for key in keys { + if !isExplicitSnapshotNamespace(namespace) { + snapshots[key] = snapshot + snapshots[snapshotWindowKey(key, snapshot.windowId)] = snapshot + if let windowIndex = requestedWindowIndex(params) { + snapshots[snapshotWindowIndexKey(key, windowIndex)] = snapshot + } + } + snapshots[namespacedSnapshotKey(namespace, key)] = snapshot + snapshots[namespacedSnapshotKey(namespace, snapshotWindowKey(key, snapshot.windowId))] = snapshot + if let windowIndex = requestedWindowIndex(params) { + snapshots[namespacedSnapshotKey(namespace, snapshotWindowIndexKey(key, windowIndex))] = snapshot + } + } + return snapshot + } + + private func currentSnapshot(params: [String: JSONValue]) throws -> Snapshot { + let cached = cachedSnapshot(params: params) + // Why: cached AX frames can be stale after a window move or resize, and + // stale geometry can turn an intended action into a misclick. + let snapshot = try observe(params: params.merging(["noScreenshot": .bool(true)]) { _, replacement in replacement }) + try validateRequestedElements(cached: cached, current: snapshot, params: params) + return snapshot + } + + private func currentKeyboardSnapshot(params: [String: JSONValue]) throws -> Snapshot { + let snapshot = try currentSnapshot(params: params.merging(["noScreenshot": .bool(true)]) { _, replacement in replacement }) + if params["restoreWindow"]?.bool != true && !isTargetWindowFocused(snapshot) { + throw ProviderError.coded("window_not_focused", "keyboard input requires the target \(snapshot.app.name) window to be focused; retry with --restore-window or use set-value for editable elements") + } + return snapshot + } + + private func cachedSnapshot(params: [String: JSONValue]) -> Snapshot? { + guard let query = params["app"]?.string, !query.isEmpty else { return nil } + let namespace = snapshotNamespace(params) + if let targetWindowId = requestedWindowId(params) { + let windowKey = snapshotWindowKey(query.lowercased(), targetWindowId) + return snapshots[namespacedSnapshotKey(namespace, windowKey)] ?? + (isExplicitSnapshotNamespace(namespace) ? nil : snapshots[windowKey]) + } + if let targetWindowIndex = requestedWindowIndex(params) { + let windowKey = snapshotWindowIndexKey(query.lowercased(), targetWindowIndex) + return snapshots[namespacedSnapshotKey(namespace, windowKey)] ?? + (isExplicitSnapshotNamespace(namespace) ? nil : snapshots[windowKey]) + } + let key = query.lowercased() + return snapshots[namespacedSnapshotKey(namespace, key)] ?? + (isExplicitSnapshotNamespace(namespace) ? nil : snapshots[key]) + } + + private func validateRequestedElements(cached: Snapshot?, current: Snapshot, params: [String: JSONValue]) throws { + let requestedKeys = ["elementIndex", "fromElementIndex", "toElementIndex"].filter { + params[$0]?.number != nil + } + guard !requestedKeys.isEmpty else { return } + guard let cached else { + throw ProviderError.coded("element_not_found", "element indexes require a fresh get-app-state snapshot for this app/window") + } + for key in requestedKeys { + guard let value = params[key]?.number else { continue } + let index = Int(value) + guard let expected = cached.elements[index], let actual = current.elements[index] else { + throw ProviderError.coded("element_not_found", "element \(index) is stale; run get-app-state again and use a fresh element index") + } + guard expected.signature == actual.signature else { + throw ProviderError.coded("element_not_found", "element \(index) changed since the last snapshot; run get-app-state again and use a fresh element index") + } + } + } + + private func ensureWindowStillAvailable(_ snapshot: Snapshot) throws { + guard WindowCapture.candidates(pid: snapshot.app.pid).contains(where: { $0.windowId == snapshot.windowId }) else { + throw ProviderError.coded("window_stale", "window \(Int(snapshot.windowId)) is no longer available; run get-app-state again to refresh the target window") + } + } + + private func promptForAccessibilityOnce() { + guard !hasPromptedForAccessibility else { + return + } + hasPromptedForAccessibility = true + _ = promptForAccessibility() + } + + private func listApps() -> [AppDescriptor] { + var seen = Set() + return NSWorkspace.shared.runningApplications + .filter { !$0.isTerminated && $0.activationPolicy == .regular } + .compactMap { app in + guard let name = app.localizedName, !name.isEmpty else { return nil } + let pid = app.processIdentifier + guard pid > 0, pidIsLive(pid) else { return nil } + let key = (app.bundleIdentifier ?? "pid:\(pid)").lowercased() + guard seen.insert(key).inserted else { return nil } + return AppDescriptor(name: name, bundleId: app.bundleIdentifier, pid: pid, app: app) + } + .sorted { lhs, rhs in + if lhs.app.isActive != rhs.app.isActive { + return lhs.app.isActive && !rhs.app.isActive + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + private func renderListedApp(_ app: AppDescriptor) -> [String: Any] { + [ + "name": app.name, + "bundleId": jsonNullable(app.bundleId), + "pid": Int(app.pid), + "isRunning": true, + "lastUsedAt": NSNull(), + "useCount": NSNull(), + ] + } + + private func providerHandshake() -> [String: Any] { + [ + "platform": "darwin", + "provider": providerName, + "providerVersion": providerVersion, + "protocolVersion": providerProtocolVersion, + "supports": [ + "apps": [ + "list": true, + "bundleIds": true, + "pids": true, + ], + "windows": [ + "list": true, + "targetById": true, + "targetByIndex": true, + "focus": false, + "moveResize": false, + ], + "observation": [ + "screenshot": true, + "annotatedScreenshot": false, + "elementFrames": true, + "ocr": false, + ], + "actions": [ + "click": true, + "typeText": true, + "pressKey": true, + "hotkey": true, + "pasteText": true, + "scroll": true, + "drag": true, + "setValue": true, + "performAction": true, + ], + "surfaces": [ + "menus": false, + "dialogs": false, + "dock": false, + "menubar": false, + ], + ], + ] + } + + private func resolveApp(_ query: String) throws -> AppDescriptor { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw ProviderError.coded("invalid_argument", "app query must not be empty") + } + if let pid = parsePid(trimmed) { + if let app = appByPid(pid) { + try rejectBlockedApp(app) + return app + } + throw ProviderError.coded("app_not_found", "app '\(trimmed)' not found") + } + if blockedBundleIds.contains(trimmed) { + throw ProviderError.coded("app_blocked", "app '\(trimmed)' is blocked for safety") + } + if let app = listApps().first(where: { matches($0, query: trimmed) }) { + try rejectBlockedApp(app) + return app + } + throw ProviderError.coded("app_not_found", "app '\(trimmed)' not found") + } + + private func rejectBlockedApp(_ app: AppDescriptor) throws { + if let bundle = app.bundleId, blockedBundleIds.contains(bundle) { + throw ProviderError.coded("app_blocked", "app '\(bundle)' is blocked for safety") + } + } + + private func listWindows(params: [String: JSONValue]) throws -> [String: Any] { + let app = try resolveApp(try requiredString(params, "app")) + let windows = WindowCapture.candidates(pid: app.pid) + .filter { $0.layer == 0 } + .enumerated() + .map { index, candidate -> [String: Any] in + [ + "index": index, + "app": [ + "name": app.name, + "bundleId": jsonNullable(app.bundleId), + "pid": Int(app.pid), + ], + "id": Int(candidate.windowId), + "title": candidate.title ?? "", + "x": Int(candidate.bounds.origin.x.rounded()), + "y": Int(candidate.bounds.origin.y.rounded()), + "width": Int(candidate.bounds.width.rounded()), + "height": Int(candidate.bounds.height.rounded()), + "isMinimized": false, + "isOffscreen": !candidate.isOnScreen, + "screenIndex": jsonNullable(screenIndex(for: candidate.bounds)), + "isMain": NSNull(), + "platform": [ + "layer": candidate.layer, + "alpha": candidate.alpha, + ], + ] + } + return [ + "app": renderListedApp(app), + "windows": windows, + ] + } + + private func appByPid(_ pid: pid_t) -> AppDescriptor? { + guard let app = NSRunningApplication(processIdentifier: pid), + !app.isTerminated, + let name = app.localizedName + else { + return nil + } + return AppDescriptor(name: name, bundleId: app.bundleIdentifier, pid: pid, app: app) + } + + private func buildSnapshot( + app: AppDescriptor, + includeScreenshot: Bool, + windowId: CGWindowID?, + windowIndex: Int?, + restoreWindow: Bool + ) throws -> Snapshot { + guard accessibilityTrusted() else { + promptForAccessibilityOnce() + throw ProviderError.coded("permission_denied", "Accessibility permission is required for Orca Computer Use.") + } + let appElement = AXUIElementCreateApplication(app.pid) + enableManualAccessibilityIfNeeded(appElement, app: app) + let windowCandidates = WindowCapture.candidates(pid: app.pid) + let focused = try focusedWindow( + appElement: appElement, + app: app, + visibleWindowCount: windowCandidates.count, + allowRecovery: restoreWindow + ) + let focusedTitle = stringAttribute(focused, kAXTitleAttribute as String) ?? app.name + guard let capture = WindowCapture.resolve(candidates: windowCandidates, titleHint: focusedTitle, windowId: windowId, windowIndex: windowIndex) else { + throw ProviderError.coded("window_not_found", "app '\(app.name)' has no on-screen window") + } + guard let window = matchingWindow(appElement: appElement, capture: capture, focused: focused, explicitTarget: windowId != nil || windowIndex != nil) else { + throw ProviderError.coded("window_not_found", "could not match accessibility window to requested window; run get-app-state again or retry without a window selector") + } + let title = stringAttribute(window, kAXTitleAttribute as String) ?? capture.title ?? app.name + let renderer = TreeRenderer(windowBounds: capture.bounds, focused: focusedElement(appElement: appElement)) + renderer.render(window) + let screenshot = includeScreenshot ? capture.screenshotPayload() : nil + let screenshotStatus: ScreenshotStatus = if screenshot != nil { + .captured + } else if includeScreenshot && !screenCaptureTrusted() { + .failed("Screen Recording permission is required for Orca Computer Use; grant permission or pass --no-screenshot to inspect accessibility state only.") + } else if includeScreenshot { + .failed("window screenshot capture returned no image; retry with --no-screenshot if accessibility state is sufficient.") + } else { + .skipped + } + return Snapshot( + id: UUID().uuidString, + app: app, + windowTitle: title, + windowBounds: capture.bounds, + windowId: capture.windowId, + windowLayer: capture.layer, + treeText: renderTreeText(app: app, title: title, bounds: capture.bounds, lines: renderer.lines, focused: renderer.focusedSummary), + focusedElementId: renderer.focusedElementId, + screenshot: screenshot, + screenshotStatus: screenshotStatus, + screenshotScale: screenshotScale(screenshot: screenshot, bounds: capture.bounds), + screenshotEngine: capture.image?.engine, + elements: renderer.records, + truncated: renderer.truncated, + maxDepthReached: renderer.maxDepthReached + ) + } + + private func renderSnapshot(_ snapshot: Snapshot) -> [String: Any] { + var screenshot: Any = NSNull() + if let payload = snapshot.screenshot { + screenshot = [ + "data": payload.data, + "format": "png", + "width": payload.width, + "height": payload.height, + "scale": payload.scale, + ] + } + return [ + "snapshot": [ + "id": snapshot.id, + "app": [ + "name": snapshot.app.name, + "bundleId": jsonNullable(snapshot.app.bundleId), + "pid": Int(snapshot.app.pid), + ], + "window": [ + "id": Int(snapshot.windowId), + "title": snapshot.windowTitle, + "x": Int(snapshot.windowBounds.origin.x.rounded()), + "y": Int(snapshot.windowBounds.origin.y.rounded()), + "width": Int(snapshot.windowBounds.width.rounded()), + "height": Int(snapshot.windowBounds.height.rounded()), + "isMinimized": false, + "isOffscreen": false, + "screenIndex": jsonNullable(screenIndex(for: snapshot.windowBounds)), + "platform": [ + "layer": snapshot.windowLayer, + ], + ], + "coordinateSpace": "window", + "treeText": snapshot.treeText, + "elementCount": snapshot.elements.count, + "focusedElementId": snapshot.focusedElementId as Any, + "truncation": [ + "truncated": snapshot.truncated, + "maxNodes": TreeRenderer.maxNodes, + "maxDepth": TreeRenderer.maxDepth, + "maxDepthReached": snapshot.maxDepthReached, + ], + ], + "screenshot": screenshot, + "screenshotStatus": renderScreenshotStatus(snapshot.screenshotStatus, snapshot: snapshot), + ] + } + + private func renderActionResult(action: [String: Any], snapshot: Snapshot) -> [String: Any] { + var result = renderSnapshot(snapshot) + var metadata = action + metadata["targetWindowId"] = Int(snapshot.windowId) + result["action"] = metadata + return result + } + + private func click(params: [String: JSONValue]) throws -> [String: Any] { + let snapshot = try currentSnapshot(params: params) + let button = params["mouseButton"]?.string ?? "left" + let count = Int(params["clickCount"]?.number ?? 1) + if let elementIndex = params["elementIndex"]?.number { + let record = try element(snapshot, Int(elementIndex)) + if count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) { + return actionMetadata(path: "accessibility", actionName: actionName) + } + if let point = center(record.localFrame, in: snapshot.windowBounds) { + try Input.click( + pid: snapshot.app.pid, + at: point, + button: mouseButton(button), + count: count + ) + return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported") + } + throw ProviderError.coded("element_not_clickable", "element \(record.index) has no clickable frame") + } + let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot) + try Input.click( + pid: snapshot.app.pid, + at: point, + button: mouseButton(button), + count: count + ) + return actionMetadata(path: "synthetic") + } + + private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? { + if mouseButton == "right" { + return performAction(record.element, "AXShowMenu") ? "AXShowMenu" : nil + } + for action in ["AXPress", "AXConfirm", "AXOpen"] { + if performAction(record.element, action) { + return action + } + } + return nil + } + + private func performSecondaryAction(params: [String: JSONValue]) throws -> [String: Any] { + let snapshot = try currentSnapshot(params: params) + let record = try element(snapshot, Int(try requiredNumber(params, "elementIndex"))) + let requested = try requiredString(params, "action") + let action = record.actions.first { SnapshotRenderHeuristics.prettyAction($0).caseInsensitiveCompare(requested) == .orderedSame || $0.caseInsensitiveCompare(requested) == .orderedSame } + guard let action else { + throw ProviderError.coded("action_not_supported", "'\(requested)' is not a valid secondary action for element \(record.index)") + } + guard performAction(record.element, action) else { + throw ProviderError.coded("accessibility_error", "AXUIElementPerformAction(\(action)) failed") + } + return actionMetadata(path: "accessibility", actionName: action) + } + + private func setValue(params: [String: JSONValue]) throws -> [String: Any] { + let snapshot = try currentSnapshot(params: params) + let record = try element(snapshot, Int(try requiredNumber(params, "elementIndex"))) + guard isSettable(record.element, kAXValueAttribute as String) else { + throw ProviderError.coded("value_not_settable", "element \(record.index) is not settable") + } + let result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, try requiredStringAllowingEmpty(params, "value") as CFString) + guard result == .success else { + throw ProviderError.coded("accessibility_error", "AXUIElementSetAttributeValue failed with \(result.rawValue)") + } + return actionMetadata(path: "accessibility", actionName: "AXSetValue") + } + + private func typeText(params: [String: JSONValue]) throws -> [String: Any] { + let snapshot = try currentKeyboardSnapshot(params: params) + try Input.typeText(try requiredString(params, "text"), pid: snapshot.app.pid) + return actionMetadata(path: "synthetic") + } + + private func pressKey(params: [String: JSONValue]) throws -> [String: Any] { + let snapshot = try currentKeyboardSnapshot(params: params) + try Input.pressKey(try requiredString(params, "key"), pid: snapshot.app.pid) + return actionMetadata(path: "synthetic") + } + + private func hotkey(params: [String: JSONValue]) throws -> [String: Any] { + let snapshot = try currentKeyboardSnapshot(params: params) + let key = try requiredString(params, "key") + if isSelectAllHotkey(key), let focused = focusedRecord(snapshot), TextInput.selectAll(focused.element) { + return actionMetadata( + path: "accessibility", + actionName: "AXSelectAll", + verification: TextInput.selectionVerification(focused.element) + ) + } + try Input.pressKey(key, pid: snapshot.app.pid) + return actionMetadata( + path: "synthetic", + actionName: "hotkey", + verification: unverifiedAction(reason: "synthetic_input") + ) + } + + private func pasteText(params: [String: JSONValue]) throws -> [String: Any] { + let snapshot = try currentKeyboardSnapshot(params: params) + let text = try requiredString(params, "text") + if let focused = focusedRecord(snapshot), let verification = TextInput.replaceSelection(focused.element, with: text) { + return actionMetadata(path: "accessibility", actionName: "AXReplaceSelection", verification: verification) + } + try Input.pasteText(text, pid: snapshot.app.pid) + return actionMetadata( + path: "clipboard", + actionName: "paste", + verification: unverifiedAction(reason: "clipboard_paste") + ) + } + + private func scroll(params: [String: JSONValue]) throws -> [String: Any] { + let snapshot = try currentSnapshot(params: params) + let direction = try requiredString(params, "direction") + let pages = params["pages"]?.number ?? 1 + if let elementIndex = params["elementIndex"]?.number { + let record = try element(snapshot, Int(elementIndex)) + let action = "AXScroll\(direction.capitalized)ByPage" + if pages.rounded() == pages, record.actions.contains(action) { + for _ in 0.. [String: Any] { + let snapshot = try currentSnapshot(params: params) + let start: CGPoint + let end: CGPoint + if let fromIndex = params["fromElementIndex"]?.number, let toIndex = params["toElementIndex"]?.number { + let from = try element(snapshot, Int(fromIndex)) + let to = try element(snapshot, Int(toIndex)) + guard let fromPoint = center(from.localFrame, in: snapshot.windowBounds), + let toPoint = center(to.localFrame, in: snapshot.windowBounds) + else { + throw ProviderError.coded("element_not_found", "drag element has no frame") + } + start = fromPoint + end = toPoint + } else { + start = try coordinatePoint(params: params, xKey: "fromX", yKey: "fromY", snapshot: snapshot) + end = try coordinatePoint(params: params, xKey: "toX", yKey: "toY", snapshot: snapshot) + } + try Input.drag(pid: snapshot.app.pid, from: start, to: end) + return actionMetadata(path: "synthetic") + } + + private func element(_ snapshot: Snapshot, _ index: Int) throws -> ElementRecord { + guard let record = snapshot.elements[index] else { + throw ProviderError.coded("element_not_found", "element \(index) is not in the current cached snapshot for \(snapshot.app.name); run get-app-state again and use a fresh element index") + } + return record + } + + private func focusedRecord(_ snapshot: Snapshot) -> ElementRecord? { + guard let focusedElementId = snapshot.focusedElementId else { + return nil + } + return snapshot.elements[focusedElementId] + } +} + +private let blockedBundleIds: Set = [ + "com.1password.1password", + "com.1password.safari", + "com.bitwarden.desktop", + "com.dashlane.dashlanephonefinal", + "com.lastpass.LastPass", + "com.nordsec.nordpass", + "me.proton.pass.electron", + "me.proton.pass.catalyst", +] + +private func requiredString(_ params: [String: JSONValue], _ key: String) throws -> String { + guard let value = params[key]?.string, !value.isEmpty else { + throw ProviderError.coded("invalid_argument", "missing \(key)") + } + return value +} + +private func requiredStringAllowingEmpty(_ params: [String: JSONValue], _ key: String) throws -> String { + guard let value = params[key]?.string else { + throw ProviderError.coded("invalid_argument", "missing \(key)") + } + return value +} + +private func requiredNumber(_ params: [String: JSONValue], _ key: String) throws -> Double { + guard let value = params[key]?.number, value.isFinite else { + throw ProviderError.coded("invalid_argument", "missing \(key)") + } + return value +} + +private func parsePid(_ query: String) -> pid_t? { + guard query.hasPrefix("pid:") else { return nil } + guard let pid = Int32(query.dropFirst(4)), pid > 0 else { return nil } + return pid +} + +private func matches(_ app: AppDescriptor, query: String) -> Bool { + app.name.caseInsensitiveCompare(query) == .orderedSame || + app.bundleId?.caseInsensitiveCompare(query) == .orderedSame +} + +private func pidIsLive(_ pid: pid_t) -> Bool { + kill(pid, 0) == 0 +} + +private func accessibilityTrusted() -> Bool { + AXIsProcessTrusted() +} + +private func promptForAccessibility() -> Bool { + let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary + return AXIsProcessTrustedWithOptions(options) +} + +private func screenCaptureTrusted() -> Bool { + CGPreflightScreenCaptureAccess() +} + +private func requestScreenCaptureAccess() -> Bool { + CGRequestScreenCaptureAccess() +} + +private func openAccessibilitySettings() { + openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") +} + +private func openScreenRecordingSettings() { + openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") +} + +private func openSystemSettings(_ value: String) { + guard let url = URL(string: value) else { return } + NSWorkspace.shared.open(url) +} + +private func enableManualAccessibilityIfNeeded(_ appElement: AXUIElement, app: AppDescriptor) { + guard app.needsManualAccessibilityMode else { + return + } + _ = AXUIElementSetAttributeValue(appElement, "AXManualAccessibility" as CFString, kCFBooleanTrue) + _ = AXUIElementSetAttributeValue(appElement, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) +} + +private func focusedWindow(appElement: AXUIElement, app: AppDescriptor, visibleWindowCount: Int, allowRecovery: Bool) throws -> AXUIElement { + let systemWide = AXUIElementCreateSystemWide() + if let window = focusedSystemWindow(systemWide: systemWide, app: app) { + return window + } + if let window = copyElement(appElement, kAXFocusedWindowAttribute as String), usableWindow(window) { + return window + } + if let windows = copyArray(appElement, kAXWindowsAttribute as String) { + if let window = windows.first(where: usableWindow) { + return window + } + } + if allowRecovery { + recoverWindow(app) + if let window = focusedSystemWindow(systemWide: systemWide, app: app) { + return window + } + if let window = copyElement(appElement, kAXFocusedWindowAttribute as String), usableWindow(window) { + return window + } + if let windows = copyArray(appElement, kAXWindowsAttribute as String) { + if let window = windows.first(where: usableWindow) { + return window + } + } + } + let permissionHint = visibleWindowCount > 0 + ? " The app has visible windows, so macOS Accessibility may need Orca Computer Use toggled off and on again in System Settings." + : "" + throw ProviderError.coded("window_not_found", "app '\(app.name)' has no accessibility window; make sure the app has a visible window, then retry with --restore-window.\(permissionHint)") +} + +private func focusedSystemWindow(systemWide: AXUIElement, app: AppDescriptor) -> AXUIElement? { + guard let focusedApp = copyElement(systemWide, kAXFocusedApplicationAttribute as String), + pidAttribute(focusedApp) == app.pid + else { + return nil + } + if let window = copyElement(systemWide, kAXFocusedWindowAttribute as String), usableWindow(window) { + return window + } + if let window = copyElement(focusedApp, kAXFocusedWindowAttribute as String), usableWindow(window) { + return window + } + if let windows = copyArray(focusedApp, kAXWindowsAttribute as String) { + return windows.first(where: usableWindow) + } + return nil +} + +private func isTargetWindowFocused(_ snapshot: Snapshot) -> Bool { + guard let focusedWindow = focusedSystemWindow(systemWide: AXUIElementCreateSystemWide(), app: snapshot.app) else { + return false + } + if windowNumber(focusedWindow) == snapshot.windowId { + return true + } + guard let frame = absoluteFrame(focusedWindow) else { + return false + } + let intersection = frame.intersection(snapshot.windowBounds) + return !intersection.isNull && intersection.area >= min(frame.area, snapshot.windowBounds.area) * 0.75 +} + +private func matchingWindow(appElement: AXUIElement, capture: WindowCapture, focused: AXUIElement, explicitTarget: Bool) -> AXUIElement? { + guard let windows = copyArray(appElement, kAXWindowsAttribute as String) else { + return nil + } + if let byNumber = windows.first(where: { windowNumber($0) == capture.windowId }) { + return byNumber + } + if let byBounds = windows.first(where: { window in + guard usableWindow(window), let frame = absoluteFrame(window) else { return false } + let intersection = frame.intersection(capture.bounds) + return !intersection.isNull && intersection.area >= min(frame.area, capture.bounds.area) * 0.75 + }) { + return byBounds + } + if explicitTarget { + return nil + } + guard let titleHint = capture.title, !titleHint.isEmpty else { + return focused + } + return windows.first { + usableWindow($0) && stringAttribute($0, kAXTitleAttribute as String) == titleHint + } ?? focused +} + +private func recoverWindow(_ app: AppDescriptor) { + _ = app.app.unhide() + _ = app.app.activate(options: [.activateAllWindows]) + if let bundleId = app.bundleId { + openBundle(bundleId) + } + let appElement = AXUIElementCreateApplication(app.pid) + if let window = copyElement(appElement, kAXFocusedWindowAttribute as String) ?? copyArray(appElement, kAXWindowsAttribute as String)?.first { + _ = AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, kCFBooleanFalse) + _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString) + _ = AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, kCFBooleanTrue) + _ = AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, kCFBooleanTrue) + } + Thread.sleep(forTimeInterval: 0.4) +} + +private func openBundle(_ bundleId: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-b", bundleId] + process.standardOutput = Pipe() + process.standardError = Pipe() + try? process.run() + process.waitUntilExit() +} + +private func requestedWindowId(_ params: [String: JSONValue]) -> CGWindowID? { + guard let value = params["windowId"]?.number, value >= 0 else { return nil } + return CGWindowID(UInt32(value)) +} + +private func snapshotWindowKey(_ query: String, _ windowId: CGWindowID) -> String { + "\(query.lowercased())#window:\(Int(windowId))" +} + +private func snapshotWindowIndexKey(_ query: String, _ windowIndex: Int) -> String { + "\(query.lowercased())#windowIndex:\(windowIndex)" +} + +private func snapshotNamespace(_ params: [String: JSONValue]) -> String { + if let session = params["session"]?.string, !session.isEmpty { + return "session:\(session)" + } + if let worktree = params["worktree"]?.string, !worktree.isEmpty { + return "worktree:\(worktree)" + } + return "default" +} + +private func namespacedSnapshotKey(_ namespace: String, _ key: String) -> String { + "\(namespace):\(key.lowercased())" +} + +private func isExplicitSnapshotNamespace(_ namespace: String) -> Bool { + namespace != "default" +} + +private func requestedWindowIndex(_ params: [String: JSONValue]) -> Int? { + guard let value = params["windowIndex"]?.number, value >= 0 else { return nil } + return Int(value) +} + +private func usableWindow(_ element: AXUIElement) -> Bool { + stringAttribute(element, kAXRoleAttribute as String) == kAXWindowRole as String && + boolAttribute(element, kAXMinimizedAttribute as String) != true +} + +private func focusedElement(appElement: AXUIElement) -> AXUIElement? { + copyElement(appElement, kAXFocusedUIElementAttribute as String) +} + +private func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else { + return nil + } + return (value as! AXUIElement) +} + +private func copyArray(_ element: AXUIElement, _ attribute: String) -> [AXUIElement]? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else { + return nil + } + return value as? [AXUIElement] +} + +private func pidAttribute(_ element: AXUIElement) -> pid_t? { + var pid: pid_t = 0 + guard AXUIElementGetPid(element, &pid) == .success else { + return nil + } + return pid +} + +private func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else { + return nil + } + if CFGetTypeID(value) == CFStringGetTypeID(), let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if CFGetTypeID(value) == CFURLGetTypeID(), let url = value as? URL { + return url.absoluteString + } + return nil +} + +private func rawStringAttribute(_ element: AXUIElement, _ attribute: String) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let value, + CFGetTypeID(value) == CFStringGetTypeID() + else { + return nil + } + return value as? String +} + +private func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { + return nil + } + return value as? Bool +} + +private func numberAttribute(_ element: AXUIElement, _ attribute: String) -> NSNumber? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { + return nil + } + return value as? NSNumber +} + +private func windowNumber(_ element: AXUIElement) -> CGWindowID? { + guard let number = numberAttribute(element, "AXWindowNumber") else { + return nil + } + return CGWindowID(number.uint32Value) +} + +private func absoluteFrame(_ element: AXUIElement) -> CGRect? { + var positionValue: CFTypeRef? + var sizeValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionValue) == .success, + AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeValue) == .success, + let positionValue, + let sizeValue + else { + return nil + } + var point = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) + else { + return nil + } + return CGRect(origin: point, size: size) +} + +private extension CGRect { + var area: CGFloat { + max(width, 0) * max(height, 0) + } +} + +private func actions(_ element: AXUIElement) -> [String] { + var value: CFArray? + guard AXUIElementCopyActionNames(element, &value) == .success, let value else { + return [] + } + return value as? [String] ?? [] +} + +private func performAction(_ element: AXUIElement, _ action: String) -> Bool { + actions(element).contains(where: { $0.caseInsensitiveCompare(action) == .orderedSame }) && + AXUIElementPerformAction(element, action as CFString) == .success +} + +private func isSettable(_ element: AXUIElement, _ attribute: String) -> Bool { + var settable = DarwinBoolean(false) + return AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) == .success && settable.boolValue +} + +private func frame(_ element: AXUIElement, windowBounds: CGRect) -> CGRect? { + var positionValue: CFTypeRef? + var sizeValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionValue) == .success, + AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeValue) == .success, + let positionValue, + let sizeValue + else { + return nil + } + var point = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) + else { + return nil + } + return CGRect(x: point.x - windowBounds.minX, y: point.y - windowBounds.minY, width: size.width, height: size.height) +} + +private func elementSignature(_ node: SnapshotRenderNode) -> String { + [ + node.role, + node.roleDescription ?? "", + node.title ?? "", + node.label ?? "", + node.linkText ?? "", + node.value ?? "", + node.placeholder ?? "", + node.url ?? "", + node.summary ?? "", + node.rowSummary ?? "", + SnapshotRenderHeuristics.meaningfulActions(node.rawActions, role: node.role).joined(separator: ","), + ].joined(separator: "\u{1f}") +} + +private func center(_ localFrame: CGRect?, in windowBounds: CGRect) -> CGPoint? { + guard let localFrame else { return nil } + return CGPoint(x: windowBounds.minX + localFrame.midX, y: windowBounds.minY + localFrame.midY) +} + +private func screenIndex(for bounds: CGRect) -> Int? { + guard let index = NSScreen.screens.firstIndex(where: { $0.frame.intersects(bounds) }) else { + return nil + } + return index +} + +private func coordinatePoint(params: [String: JSONValue], xKey: String, yKey: String, snapshot: Snapshot) throws -> CGPoint { + let x = try requiredNumber(params, xKey) + let y = try requiredNumber(params, yKey) + return CGPoint( + x: snapshot.windowBounds.minX + x, + y: snapshot.windowBounds.minY + y + ) +} + +private func screenshotScale(screenshot: ScreenshotPayload?, bounds: CGRect) -> CGSize { + guard let screenshot, bounds.width > 0, bounds.height > 0 else { + return CGSize(width: 1, height: 1) + } + return CGSize( + width: CGFloat(screenshot.width) / bounds.width, + height: CGFloat(screenshot.height) / bounds.height + ) +} + +private enum MouseButton { + case left + case right + + var cgButton: CGMouseButton { + switch self { + case .left: + return .left + case .right: + return .right + } + } + + var downEvent: CGEventType { + switch self { + case .left: + return .leftMouseDown + case .right: + return .rightMouseDown + } + } + + var upEvent: CGEventType { + switch self { + case .left: + return .leftMouseUp + case .right: + return .rightMouseUp + } + } +} + +private func mouseButton(_ raw: String?) throws -> MouseButton { + switch raw ?? "left" { + case "left": + return .left + case "right": + return .right + case "middle": + throw ProviderError.coded("invalid_argument", "middle-click is not yet supported") + case let value: + throw ProviderError.coded("invalid_argument", "unsupported mouse button '\(value)'") + } +} + +private func renderTreeText(app: AppDescriptor, title: String, bounds: CGRect, lines: [String], focused: String?) -> String { + var output = [ + "App=\(app.bundleId ?? app.name.replacingOccurrences(of: " ", with: "_")) (pid \(app.pid))", + "Window: \"\(sanitize(title))\", App: \(sanitize(app.name)).", + "", + ] + output.append(contentsOf: lines) + output.append("") + output.append(focused.map { "The focused UI element is \($0)." } ?? "No UI element is currently focused.") + return output.joined(separator: "\n") +} + +private func renderScreenshotStatus(_ status: ScreenshotStatus, snapshot: Snapshot) -> [String: Any] { + let metadata: [String: Any] = [ + "engine": snapshot.screenshotEngine ?? "unknown", + "windowId": Int(snapshot.windowId), + ] + switch status { + case .captured: + return ["state": "captured", "metadata": metadata] + case .skipped: + return ["state": "skipped", "reason": "no_screenshot_flag"] + case let .failed(message): + return ["state": "failed", "code": "screenshot_failed", "message": message, "metadata": metadata] + } +} + +private final class TreeRenderer { + let windowBounds: CGRect + let focused: AXUIElement? + var lines: [String] = [] + var records: [Int: ElementRecord] = [:] + var focusedSummary: String? + var focusedElementId: Int? + var truncated = false + var maxDepthReached = false + private var nextIndex = 0 + static let maxNodes = 1200 + static let maxDepth = 64 + + init(windowBounds: CGRect, focused: AXUIElement?) { + self.windowBounds = windowBounds + self.focused = focused + } + + func render(_ element: AXUIElement, depth: Int = 0, ancestors: [AXUIElement] = []) { + guard nextIndex < Self.maxNodes else { + truncated = true + return + } + guard depth < Self.maxDepth else { + truncated = true + maxDepthReached = true + return + } + guard !ancestors.contains(where: { CFEqual($0, element) }) else { return } + + let role = stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown" + let children = primaryChildren(element, role: role, windowBounds: windowBounds) + let value = valueString(element) + let placeholder = placeholderString(element) + let rawActions = actions(element) + let rowSummary = rowTextSummary(element, role: role) + let linkText = role == "AXLink" ? descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil + let baseNode = SnapshotRenderNode( + role: role, + roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String), + title: stringAttribute(element, kAXTitleAttribute as String), + label: stringAttribute(element, kAXDescriptionAttribute as String), + linkText: linkText, + value: value, + placeholder: placeholder, + url: stringAttribute(element, kAXURLAttribute as String), + traits: [], + rawActions: rawActions, + childCount: children.count, + rowSummary: rowSummary + ) + let name = SnapshotRenderHeuristics.displayName(baseNode) + let meaningful = SnapshotRenderHeuristics.meaningfulActions(rawActions, role: role) + let localFrame = frame(element, windowBounds: windowBounds) + let traits = traitsFor(element, role: role) + let webAreaDepth = webAreaDepth(role: role, ancestors: ancestors) + let summary = genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits) + let node = SnapshotRenderNode( + role: role, + roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String), + title: stringAttribute(element, kAXTitleAttribute as String), + label: stringAttribute(element, kAXDescriptionAttribute as String), + linkText: linkText, + value: value, + placeholder: placeholder, + url: stringAttribute(element, kAXURLAttribute as String), + traits: traits, + rawActions: rawActions, + childCount: children.count, + summary: summary, + rowSummary: rowSummary, + webAreaDepth: webAreaDepth + ) + if SnapshotRenderHeuristics.shouldElide(node) { + for child in children { + render(child, depth: depth, ancestors: ancestors + [element]) + } + return + } + + let index = nextIndex + nextIndex += 1 + let line = SnapshotRenderHeuristics.line(index: index, node: node) + lines.append(String(repeating: "\t", count: depth) + line) + records[index] = ElementRecord( + index: index, + element: element, + localFrame: localFrame, + actions: rawActions, + signature: elementSignature(node) + ) + if let focused, CFEqual(focused, element) { + focusedElementId = index + focusedSummary = line + } + if summary != nil || SnapshotRenderHeuristics.shouldSuppressChildren(node) { + return + } + for child in children { + render(child, depth: depth + 1, ancestors: ancestors + [element]) + } + } +} + +private func valueString(_ element: AXUIElement) -> String? { + if isSecureTextElement(element) { + return "[redacted]" + } + if let string = stringAttribute(element, kAXValueAttribute as String) { + return string + } + if let number = numberAttribute(element, kAXValueAttribute as String) { + return number.stringValue + } + return nil +} + +private func isSecureTextElement(_ element: AXUIElement) -> Bool { + let role = stringAttribute(element, kAXRoleAttribute as String)?.lowercased() ?? "" + let subrole = stringAttribute(element, kAXSubroleAttribute as String)?.lowercased() ?? "" + let title = stringAttribute(element, kAXTitleAttribute as String)?.lowercased() ?? "" + let description = stringAttribute(element, kAXDescriptionAttribute as String)?.lowercased() ?? "" + let placeholder = placeholderString(element)?.lowercased() ?? "" + let haystack = [role, subrole, title, description, placeholder].joined(separator: " ") + return haystack.contains("secure") || + haystack.contains("password") || + haystack.contains("passcode") || + haystack.contains("verification code") || + haystack.contains("one-time code") +} + +private func placeholderString(_ element: AXUIElement) -> String? { + stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder") +} + +private func traitsFor(_ element: AXUIElement, role: String) -> [String] { + var traits: [String] = [] + if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") } + if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") } + if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") } + if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") } + return traits +} + +private let valueSettableRoles: Set = [ + kAXCheckBoxRole as String, + kAXComboBoxRole as String, + kAXRadioButtonRole as String, + "AXSearchField", + kAXSliderRole as String, + kAXTextAreaRole as String, + kAXTextFieldRole as String, +] + +private func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] { + if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty { + return visibleRows(rows, parent: element, windowBounds: windowBounds) + } + return copyArray(element, kAXChildrenAttribute as String) ?? [] +} + +private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] { + guard let parentFrame = frame(parent, windowBounds: windowBounds) else { + return Array(rows.prefix(20)) + } + let visible = rows.filter { row in + guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false } + return rowFrame.intersects(parentFrame) + } + return Array((visible.isEmpty ? rows : visible).prefix(20)) +} + +private func usesRowsAsPrimaryChildren(role: String) -> Bool { + [ + kAXBrowserRole as String, + kAXListRole as String, + kAXOutlineRole as String, + kAXTableRole as String, + ].contains(role) +} + +private func genericTextSummary( + _ element: AXUIElement, + role: String, + name: String?, + actions: [String], + traits: [String] +) -> String? { + guard (role == kAXGroupRole as String || role == kAXUnknownRole as String), + name == nil, + actions.isEmpty, + traits.isEmpty, + isPlainTextSubtree(element, maxDepth: 4) + else { + return nil + } + let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4) + guard texts.count >= 2 else { return nil } + let summary = texts.joined(separator: " ") + guard summary.count <= 220 else { return nil } + return summary +} + +private func rowTextSummary(_ element: AXUIElement, role: String) -> String? { + guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil } + let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3) + guard !texts.isEmpty else { return nil } + return texts.joined(separator: " ") +} + +private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool { + var sawText = false + let allowedContainerRoles: Set = [ + kAXGroupRole as String, + kAXUnknownRole as String, + kAXStaticTextRole as String, + "AXLink", + "AXImage", + ] + + func visit(_ node: AXUIElement, depth: Int) -> Bool { + guard depth <= maxDepth else { return false } + let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown" + guard allowedContainerRoles.contains(role) else { return false } + if role == kAXStaticTextRole as String || role == "AXLink" { + sawText = true + } + guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false } + for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { + guard visit(child, depth: depth + 1) else { return false } + } + return true + } + + return visit(element, depth: 0) && sawText +} + +private func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] { + var values: [String] = [] + var seen = Set() + + func collect(_ node: AXUIElement, depth: Int) { + guard values.count < limit, depth <= maxDepth else { return } + let role = stringAttribute(node, kAXRoleAttribute as String) ?? "" + if role == kAXStaticTextRole as String || role == "AXLink" { + for candidate in [ + stringAttribute(node, kAXValueAttribute as String), + stringAttribute(node, kAXTitleAttribute as String), + stringAttribute(node, kAXDescriptionAttribute as String), + ] { + guard let candidate else { continue } + let text = preview(candidate, maxLength: 80) + guard !text.isEmpty, seen.insert(text).inserted else { continue } + values.append(text) + if values.count >= limit { return } + } + } + for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { + collect(child, depth: depth + 1) + if values.count >= limit { return } + } + } + + collect(element, depth: 0) + return values +} + +private func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? { + if role == "AXWebArea" { return 0 } + guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else { + return nil + } + return ancestors.count - index +} + +private func sanitize(_ value: String) -> String { + value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ") +} + +private func preview(_ value: String, maxLength: Int = 120) -> String { + let clean = sanitize(value) + if clean.count <= maxLength { + return clean + } + return String(clean.prefix(maxLength)) + "..." +} + +private func actionMetadata( + path: String, + actionName: String? = nil, + fallbackReason: String? = nil, + verification: [String: Any]? = nil +) -> [String: Any] { + var metadata: [String: Any] = [ + "path": path, + "actionName": jsonNullable(actionName), + "fallbackReason": jsonNullable(fallbackReason), + ] + if let verification { + metadata["verification"] = verification + } + return metadata +} + +private func verifiedAction(property: String, expected: String? = nil, actualPreview: String? = nil) -> [String: Any] { + [ + "state": "verified", + "property": property, + "expected": jsonNullable(expected), + "actualPreview": jsonNullable(actualPreview), + ] +} + +private func unverifiedAction(reason: String) -> [String: Any] { + [ + "state": "unverified", + "reason": reason, + ] +} + +private func jsonNullable(_ value: T?) -> Any { + value ?? NSNull() +} + +private struct WindowCandidate { + let windowId: CGWindowID + let layer: Int + let bounds: CGRect + let title: String? + let alpha: CGFloat + let isOnScreen: Bool + let sharingState: Int? + + var score: Int { + var value = Int(bounds.width * bounds.height) + if layer == 0 { value += 1_000_000_000 } + if title != nil && title?.isEmpty == false { value += 10_000_000 } + if isOnScreen { value += 1_000_000 } + if alpha >= 0.99 { value += 100_000 } + return value + } +} + +private struct WindowCapture { + let windowId: CGWindowID + let layer: Int + let bounds: CGRect + let title: String? + let image: CapturedImage? + + static func resolve(pid: pid_t, titleHint: String?, windowId: CGWindowID?, windowIndex: Int?) -> WindowCapture? { + resolve(candidates: candidates(pid: pid), titleHint: titleHint, windowId: windowId, windowIndex: windowIndex) + } + + static func resolve(candidates: [WindowCandidate], titleHint: String?, windowId: CGWindowID?, windowIndex: Int?) -> WindowCapture? { + if let windowId { + guard let candidate = candidates.first(where: { $0.windowId == windowId }) else { return nil } + return WindowCapture(candidate: candidate) + } + if let windowIndex { + let visibleWindows = candidates.filter { $0.layer == 0 } + guard visibleWindows.indices.contains(windowIndex) else { return nil } + return WindowCapture(candidate: visibleWindows[windowIndex]) + } + guard let best = candidates.sorted(by: { lhs, rhs in + if let titleHint, lhs.title == titleHint, rhs.title != titleHint { return true } + if let titleHint, rhs.title == titleHint, lhs.title != titleHint { return false } + return lhs.score > rhs.score + }).first else { + return nil + } + return WindowCapture(candidate: best) + } + + private init(candidate: WindowCandidate) { + self.windowId = candidate.windowId + self.layer = candidate.layer + self.bounds = candidate.bounds + self.title = candidate.title + self.image = Self.captureImage(windowId: candidate.windowId, bounds: candidate.bounds) + } + + static func candidates(pid: pid_t) -> [WindowCandidate] { + guard let infos = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] else { + return [] + } + return infos.compactMap { info in + guard let ownerPid = info[kCGWindowOwnerPID as String] as? pid_t, ownerPid == pid, + let number = info[kCGWindowNumber as String] as? NSNumber, + let layer = info[kCGWindowLayer as String] as? Int, + let boundsDictionary = info[kCGWindowBounds as String] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: boundsDictionary), + bounds.width >= 48, + bounds.height >= 48 + else { + return nil + } + let alpha = info[kCGWindowAlpha as String] as? CGFloat ?? 1 + guard alpha > 0.01 else { return nil } + let sharing = (info[kCGWindowSharingState as String] as? NSNumber).map { $0.intValue } + if sharing == 0 { return nil } + let isOnScreen = (info[kCGWindowIsOnscreen as String] as? Bool) ?? true + return WindowCandidate( + windowId: CGWindowID(number.uint32Value), + layer: layer, + bounds: bounds, + title: info[kCGWindowName as String] as? String, + alpha: alpha, + isOnScreen: isOnScreen, + sharingState: sharing + ) + } + .sorted { lhs, rhs in + lhs.score > rhs.score + } + } + + func screenshotPayload() -> ScreenshotPayload? { + guard let image, let bounded = boundedPngData(image.image) else { return nil } + return ScreenshotPayload( + data: bounded.data.base64EncodedString(), + width: bounded.width, + height: bounded.height, + scale: Double(bounded.width) / max(Double(bounds.width), 1) + ) + } + + private static func captureImage(windowId: CGWindowID, bounds: CGRect) -> CapturedImage? { + if ProcessInfo.processInfo.environment["ORCA_COMPUTER_USE_SCK_SCREENSHOTS"] == "1", + let image = captureImageWithScreenCaptureKit(windowId: windowId, bounds: bounds) { + return CapturedImage(image: image, engine: "screenCaptureKit") + } + if let image = CGWindowListCreateImage(.null, [.optionIncludingWindow], windowId, [.boundsIgnoreFraming, .bestResolution]) { + return CapturedImage(image: image, engine: "cgWindowList") + } + return nil + } + + private static func captureImageWithScreenCaptureKit(windowId: CGWindowID, bounds: CGRect) -> CGImage? { + try? BlockingAsync.run(timeout: 3) { + let content = try await SCShareableContent.current + guard let window = content.windows.first(where: { $0.windowID == windowId }) else { + return nil + } + let filter = SCContentFilter(desktopIndependentWindow: window) + let configuration = SCStreamConfiguration() + let scale = NSScreen.screens.first(where: { $0.frame.intersects(bounds) })?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + configuration.width = size_t(max(1, Int((bounds.width * scale).rounded(.up)))) + configuration.height = size_t(max(1, Int((bounds.height * scale).rounded(.up)))) + configuration.scalesToFit = true + configuration.preservesAspectRatio = true + configuration.showsCursor = false + configuration.ignoreShadowsSingleWindow = true + configuration.ignoreGlobalClipSingleWindow = true + return try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) + } + } +} + +private final class AsyncBox: @unchecked Sendable { + var result: Result? +} + +private enum BlockingAsync { + static func run(timeout: TimeInterval, operation: @escaping @Sendable () async throws -> T) throws -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = AsyncBox() + let task = Task.detached { + do { + box.result = .success(try await operation()) + } catch { + box.result = .failure(error) + } + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + timeout) == .success else { + task.cancel() + throw ProviderError.coded("action_timeout", "screenshot capture timed out") + } + return try box.result!.get() + } +} + +private struct BoundedPNG { + let data: Data + let width: Int + let height: Int +} + +private func boundedPngData(_ image: CGImage) -> BoundedPNG? { + let rep = NSBitmapImageRep(cgImage: image) + guard let data = rep.representation(using: .png, properties: [:]) else { return nil } + if data.count <= 900_000 { + return BoundedPNG(data: data, width: image.width, height: image.height) + } + var scale = min(1, 1280 / CGFloat(max(image.width, image.height))) + var best = BoundedPNG(data: data, width: image.width, height: image.height) + while scale >= 0.25 { + guard let resized = resizePng(image, scale: scale) else { + break + } + best = resized + if resized.data.count <= 900_000 { + return resized + } + scale *= 0.85 + } + return best +} + +private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? { + let width = max(1, Int(CGFloat(image.width) * scale)) + let height = max(1, Int(CGFloat(image.height) * scale)) + guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { + return nil + } + context.interpolationQuality = .medium + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let resized = context.makeImage() else { return nil } + guard let data = NSBitmapImageRep(cgImage: resized).representation(using: .png, properties: [:]) else { + return nil + } + return BoundedPNG(data: data, width: width, height: height) +} + +private enum Input { + static func click(pid: pid_t, at point: CGPoint, button: MouseButton, count: Int) throws { + guard let source = CGEventSource(stateID: .combinedSessionState) else { + throw ProviderError.coded("accessibility_error", "failed to create event source") + } + for _ in 0.. [String: Any]? { + guard isSettable(element, kAXValueAttribute as String), + let current = rawStringAttribute(element, kAXValueAttribute as String) + else { + return nil + } + let selectedRange = selectedTextRange(element) ?? CFRange(location: current.utf16.count, length: 0) + let startOffset = max(0, min(selectedRange.location, current.utf16.count)) + let endOffset = max(startOffset, min(startOffset + selectedRange.length, current.utf16.count)) + let start = String.Index(utf16Offset: startOffset, in: current) + let end = String.Index(utf16Offset: endOffset, in: current) + let next = String(current[.. Bool { + guard let current = rawStringAttribute(element, kAXValueAttribute as String) else { + return false + } + return setSelectedTextRange(element, CFRange(location: 0, length: current.utf16.count)) + } + + static func selectionVerification(_ element: AXUIElement) -> [String: Any] { + guard let current = rawStringAttribute(element, kAXValueAttribute as String), + let selectedRange = selectedTextRange(element), + selectedRange.location == 0, + selectedRange.length == current.utf16.count + else { + return unverifiedAction(reason: "provider_unavailable") + } + return verifiedAction(property: "selection", actualPreview: preview(current)) + } + + private static func selectedTextRange(_ element: AXUIElement) -> CFRange? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &value) == .success, + let value, + CFGetTypeID(value) == AXValueGetTypeID() + else { + return nil + } + var range = CFRange(location: 0, length: 0) + guard AXValueGetValue(value as! AXValue, .cfRange, &range) else { + return nil + } + return range + } + + @discardableResult + private static func setSelectedTextRange(_ element: AXUIElement, _ range: CFRange) -> Bool { + var mutableRange = range + guard let value = AXValueCreate(.cfRange, &mutableRange) else { + return false + } + return AXUIElementSetAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, value) == .success + } +} + +private func isSelectAllHotkey(_ key: String) -> Bool { + let parts = key + .lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "+") + .split(separator: "+") + .map(String.init) + guard parts.last == "a" else { + return false + } + let modifiers = Set(parts.dropLast()) + return modifiers.contains("cmd") || + modifiers.contains("command") || + modifiers.contains("meta") || + modifiers.contains("cmdorctrl") || + modifiers.contains("commandorcontrol") +} + +private struct KeyModifier { + let keyCode: CGKeyCode + let flag: CGEventFlags +} + +private struct ParsedKey { + let keyCode: CGKeyCode + let modifiers: [KeyModifier] +} + +private enum KeyMap { + static func parse(_ spec: String) throws -> ParsedKey { + let parts = spec.split(separator: "+").map { String($0).lowercased() } + var modifiers: [KeyModifier] = [] + var keyName: String? + for part in parts { + switch part { + case "cmd", "command", "meta", "super", "cmdorctrl", "commandorcontrol": + modifiers.append(KeyModifier(keyCode: 55, flag: .maskCommand)) + case "ctrl", "control": + modifiers.append(KeyModifier(keyCode: 59, flag: .maskControl)) + case "alt", "option": + modifiers.append(KeyModifier(keyCode: 58, flag: .maskAlternate)) + case "shift": + modifiers.append(KeyModifier(keyCode: 56, flag: .maskShift)) + default: + keyName = part + } + } + guard let keyName, let keyCode = codes[keyName] else { + throw ProviderError.coded("invalid_argument", "unsupported key '\(spec)'") + } + return ParsedKey(keyCode: keyCode, modifiers: modifiers) + } + + private static let codes: [String: CGKeyCode] = [ + "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9, + "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19, + "3": 20, "4": 21, "6": 22, "5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, + "0": 29, "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "return": 36, + "enter": 36, "l": 37, "j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, + "/": 44, "n": 45, "m": 46, ".": 47, "tab": 48, "space": 49, "`": 50, + "backspace": 51, "delete": 51, "escape": 53, "esc": 53, "left": 123, "right": 124, + "down": 125, "up": 126, + ] +} + +private final class AgentRuntime: NSObject, NSApplicationDelegate { + private let socketPath: String + private let token: String? + private var listener: SocketListener? + + init(socketPath: String, token: String?) { + self.socketPath = socketPath + self.token = token + } + + func applicationDidFinishLaunching(_ notification: Notification) { + do { + let listener = try SocketListener(socketPath: socketPath, token: token) + self.listener = listener + listener.start() + } catch { + fputs("failed to start computer-use socket: \(error)\n", stderr) + NSApp.terminate(nil) + } + } + + func applicationWillTerminate(_ notification: Notification) { + listener?.stop() + } +} + +private final class PermissionRuntime: NSObject, NSApplicationDelegate { + private let initialPermission: PermissionKind? + private var windowController: PermissionWindowController? + + init(initialPermission: PermissionKind?) { + self.initialPermission = initialPermission + } + + func applicationDidFinishLaunching(_ notification: Notification) { + windowController = PermissionWindowController( + terminateWhenDragAssistantCloses: initialPermission != nil + ) + if let initialPermission { + windowController?.openPermission(initialPermission) + } else { + windowController?.showWindow(nil) + windowController?.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + initialPermission == nil + } +} + +private final class PermissionWindowController: NSWindowController { + private var dragAssistant: PermissionDragAssistantController? + private let terminateWhenDragAssistantCloses: Bool + + convenience init(terminateWhenDragAssistantCloses: Bool = false) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 315), + styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = "Enable Orca Computer Use" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.backgroundColor = PermissionPalette.background + window.center() + window.isReleasedWhenClosed = false + self.init(window: window, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses) + window.contentView = PermissionView(frame: window.contentView?.bounds ?? .zero) { [weak self] permission in + self?.showDragAssistant(for: permission) + } + } + + init(window: NSWindow?, terminateWhenDragAssistantCloses: Bool) { + self.terminateWhenDragAssistantCloses = terminateWhenDragAssistantCloses + super.init(window: window) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func showDragAssistant(for permission: PermissionKind) { + dragAssistant?.close() + dragAssistant = PermissionDragAssistantController( + permission: permission, + fallbackVisibleFrame: window?.screen?.visibleFrame, + onClose: { [weak self] in + if self?.terminateWhenDragAssistantCloses == true { + NSApp.terminate(nil) + } + } + ) + dragAssistant?.showWhenReady() + } + + func openPermission(_ permission: PermissionKind) { + permission.requestAndOpenSettings() + showDragAssistant(for: permission) + } +} + +private enum PermissionKind { + case accessibility + case screenshots + + static func parse(_ value: String?) -> PermissionKind? { + switch value { + case "accessibility": + .accessibility + case "screenshots", "screen", "screen-recording": + .screenshots + default: + nil + } + } + + var dragInstruction: String { + switch self { + case .accessibility: + "Drag Orca Computer Use into the list above to allow Accessibility." + case .screenshots: + "Drag Orca Computer Use into the list above to allow Screenshots." + } + } + + func requestAndOpenSettings() { + switch self { + case .accessibility: + openAccessibilitySettings() + case .screenshots: + _ = requestScreenCaptureAccess() + openScreenRecordingSettings() + } + } +} + +private final class PermissionView: NSView { + private let appURL = Bundle.main.bundleURL + private let showDragAssistant: (PermissionKind) -> Void + + init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void) { + self.showDragAssistant = showDragAssistant + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = PermissionPalette.background.cgColor + build() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func build() { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 10 + stack.distribution = .gravityAreas + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + let icon = NSImageView(image: NSWorkspace.shared.icon(forFile: appURL.path)) + icon.imageScaling = .scaleProportionallyUpOrDown + icon.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + icon.widthAnchor.constraint(equalToConstant: 58), + icon.heightAnchor.constraint(equalToConstant: 58) + ]) + + let title = label("Enable Orca Computer Use", size: 22, weight: .bold) + let subtitle = label( + "Grant permissions so Orca can use apps when you ask.", + size: 12, + weight: .regular + ) + subtitle.textColor = PermissionPalette.secondaryText + subtitle.alignment = .center + subtitle.maximumNumberOfLines = 3 + + let header = NSStackView(views: [icon, title, subtitle]) + header.orientation = .vertical + header.alignment = .centerX + header.spacing = 6 + header.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(header) + header.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -10).isActive = true + + stack.addArrangedSubview(permissionRow( + icon: NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility"), + title: "Accessibility", + detail: "Read and control app interfaces", + buttonTitle: "Allow" + ) { + PermissionKind.accessibility.requestAndOpenSettings() + self.showDragAssistant(.accessibility) + }) + + stack.addArrangedSubview(permissionRow( + icon: NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording"), + title: "Screenshots", + detail: "Capture windows for visual state", + buttonTitle: "Allow" + ) { + PermissionKind.screenshots.requestAndOpenSettings() + self.showDragAssistant(.screenshots) + }) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -18), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 22), + stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -20) + ]) + } + + private func permissionRow( + icon: NSImage?, + title: String, + detail: String, + buttonTitle: String, + action: @escaping () -> Void + ) -> NSView { + let row = NSView() + row.wantsLayer = true + row.layer?.cornerRadius = 14 + row.layer?.borderWidth = 1 + row.layer?.borderColor = PermissionPalette.border.cgColor + row.layer?.backgroundColor = PermissionPalette.card.cgColor + row.translatesAutoresizingMaskIntoConstraints = false + + let iconView = NSImageView(image: icon ?? NSImage()) + iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 30, weight: .regular) + iconView.contentTintColor = .controlAccentColor + iconView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = label(title, size: 13, weight: .bold) + let detailLabel = label(detail, size: 11, weight: .regular) + detailLabel.textColor = PermissionPalette.secondaryText + let textStack = NSStackView(views: [titleLabel, detailLabel]) + textStack.orientation = .vertical + textStack.alignment = .leading + textStack.spacing = 4 + textStack.translatesAutoresizingMaskIntoConstraints = false + + let button = NSButton(title: buttonTitle, target: nil, action: nil) + button.bezelStyle = .rounded + button.controlSize = .regular + button.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + button.contentTintColor = .white + button.bezelColor = .controlAccentColor + let buttonTitleAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.white, + .font: NSFont.systemFont(ofSize: 13, weight: .semibold) + ] + button.attributedTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes) + button.attributedAlternateTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes) + let target = ButtonTarget(action) + button.target = target + button.action = #selector(ButtonTarget.run) + objc_setAssociatedObject(button, "orca-action", target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + button.translatesAutoresizingMaskIntoConstraints = false + + row.addSubview(iconView) + row.addSubview(textStack) + row.addSubview(button) + NSLayoutConstraint.activate([ + row.heightAnchor.constraint(equalToConstant: 62), + iconView.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 12), + iconView.centerYAnchor.constraint(equalTo: row.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 34), + iconView.heightAnchor.constraint(equalToConstant: 34), + textStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10), + textStack.centerYAnchor.constraint(equalTo: row.centerYAnchor), + button.widthAnchor.constraint(greaterThanOrEqualToConstant: 52), + button.heightAnchor.constraint(equalToConstant: 30), + button.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12), + button.centerYAnchor.constraint(equalTo: row.centerYAnchor), + textStack.trailingAnchor.constraint(lessThanOrEqualTo: button.leadingAnchor, constant: -12) + ]) + return row + } + + private func label(_ text: String, size: CGFloat, weight: NSFont.Weight) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = NSFont.systemFont(ofSize: size, weight: weight) + label.lineBreakMode = .byWordWrapping + label.textColor = PermissionPalette.primaryText + label.translatesAutoresizingMaskIntoConstraints = false + return label + } +} + +private final class PermissionDragAssistantController: NSWindowController { + private let fallbackVisibleFrame: NSRect? + private let onClose: () -> Void + private var hasSeenSettingsWindow = false + private var followTimer: Timer? + + convenience init(permission: PermissionKind, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 390, height: 92), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + window.title = "Drag Orca Computer Use" + window.backgroundColor = .clear + window.isOpaque = false + window.isReleasedWhenClosed = false + window.level = .floating + window.hidesOnDeactivate = false + window.isFloatingPanel = true + window.becomesKeyOnlyIfNeeded = true + window.isMovableByWindowBackground = true + window.hasShadow = true + self.init(window: window, fallbackVisibleFrame: fallbackVisibleFrame, onClose: onClose) + window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self, weak window] in + window?.close() + self?.onClose() + } + } + + init(window: NSWindow?, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) { + self.fallbackVisibleFrame = fallbackVisibleFrame + self.onClose = onClose + super.init(window: window) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func showWhenReady() { + startFollowingSettingsWindow() + schedulePositionAndShow() + } + + override func close() { + followTimer?.invalidate() + followTimer = nil + super.close() + } + + private func schedulePositionAndShow() { + let delays = [0.12, 0.25, 0.4, 0.65, 0.95, 1.35, 1.8, 2.5] + for (index, delay) in delays.enumerated() { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, self.window?.isVisible != true else { return } + if self.systemSettingsIsFrontmost() && self.positionNearSettingsWindow() { + self.showWindow(nil) + self.window?.orderFrontRegardless() + } else if index == delays.count - 1 && self.systemSettingsIsFrontmost() { + self.positionFallback() + self.showWindow(nil) + self.window?.orderFrontRegardless() + } + } + } + } + + private func startFollowingSettingsWindow() { + followTimer?.invalidate() + followTimer = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.syncVisibilityWithSettingsWindow() + } + } + } + + private func syncVisibilityWithSettingsWindow() { + guard let window else { + followTimer?.invalidate() + followTimer = nil + return + } + let settingsWindowExists = systemSettingsWindowFrame() != nil + if settingsWindowExists { + hasSeenSettingsWindow = true + } else if hasSeenSettingsWindow { + NSApp.terminate(nil) + return + } + if systemSettingsIsFrontmost() && settingsWindowExists && positionNearSettingsWindow() { + if !window.isVisible { + showWindow(nil) + } + window.orderFrontRegardless() + } else if window.isVisible { + window.orderOut(nil) + } + } + + private func systemSettingsIsFrontmost() -> Bool { + let bundleId = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + return bundleId == "com.apple.systempreferences" || bundleId == "com.apple.SystemSettings" + } + + private func positionNearSettingsWindow() -> Bool { + guard let window else { return false } + guard let settingsFrame = systemSettingsWindowFrame() else { return false } + let visibleFrame = visibleFrameContaining(settingsFrame) + let x = settingsFrame.maxX - window.frame.width - 18 + let y = settingsFrame.minY + 18 + window.setFrameOrigin(clampedOrigin(x: x, y: y, window: window, visibleFrame: visibleFrame)) + return true + } + + private func positionFallback() { + guard let window else { return } + let visibleFrame = fallbackVisibleFrame ?? NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 900, height: 700) + let origin = NSPoint( + x: visibleFrame.midX - window.frame.width / 2, + y: visibleFrame.minY + 24 + ) + window.setFrameOrigin(clampedOrigin(x: origin.x, y: origin.y, window: window, visibleFrame: visibleFrame)) + } + + private func clampedOrigin(x: CGFloat, y: CGFloat, window: NSWindow, visibleFrame: NSRect) -> NSPoint { + let inset: CGFloat = 10 + let minX = visibleFrame.minX + inset + let maxX = visibleFrame.maxX - window.frame.width - inset + let minY = visibleFrame.minY + inset + let maxY = visibleFrame.maxY - window.frame.height - inset + return NSPoint( + x: min(max(x, minX), maxX), + y: min(max(y, minY), maxY) + ) + } + + private func systemSettingsWindowFrame() -> NSRect? { + guard let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { + return nil + } + for windowInfo in windows { + guard + let ownerName = windowInfo[kCGWindowOwnerName as String] as? String, + ownerName == "System Settings" || ownerName == "System Preferences", + (windowInfo[kCGWindowLayer as String] as? Int) == 0, + let bounds = windowInfo[kCGWindowBounds as String] as? [String: CGFloat], + let x = bounds["X"], + let y = bounds["Y"], + let width = bounds["Width"], + let height = bounds["Height"], + width > 520, + height > 360 + else { + continue + } + return appKitFrameForCGWindowFrame(NSRect(x: x, y: y, width: width, height: height)) + } + return nil + } + + private func appKitFrameForCGWindowFrame(_ cgFrame: NSRect) -> NSRect { + guard let screen = screenContainingCGFrame(cgFrame), + let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber + else { + return cgFrame + } + + let displayBounds = CGDisplayBounds(CGDirectDisplayID(screenNumber.uint32Value)) + return NSRect( + x: cgFrame.minX, + y: screen.frame.maxY - (cgFrame.minY - displayBounds.minY) - cgFrame.height, + width: cgFrame.width, + height: cgFrame.height + ) + } + + private func screenContainingCGFrame(_ frame: NSRect) -> NSScreen? { + NSScreen.screens.max { lhs, rhs in + cgDisplayBounds(for: lhs).intersection(frame).width * cgDisplayBounds(for: lhs).intersection(frame).height < + cgDisplayBounds(for: rhs).intersection(frame).width * cgDisplayBounds(for: rhs).intersection(frame).height + } + } + + private func cgDisplayBounds(for screen: NSScreen) -> NSRect { + guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return screen.frame + } + return CGDisplayBounds(CGDirectDisplayID(screenNumber.uint32Value)) + } + + private func visibleFrameContaining(_ frame: NSRect) -> NSRect { + let screen = NSScreen.screens.max { lhs, rhs in + lhs.frame.intersection(frame).width * lhs.frame.intersection(frame).height < + rhs.frame.intersection(frame).width * rhs.frame.intersection(frame).height + } + return screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 900, height: 700) + } +} + +private final class PermissionDragAssistantView: NSView { + private let permission: PermissionKind + private let appURL: URL + private let close: () -> Void + + init(permission: PermissionKind, appURL: URL, close: @escaping () -> Void) { + self.permission = permission + self.appURL = appURL + self.close = close + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = PermissionPalette.background.cgColor + layer?.cornerRadius = 14 + layer?.borderWidth = 1 + layer?.borderColor = PermissionPalette.border.cgColor + layer?.masksToBounds = true + build() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func build() { + let closeButton = NSButton(title: "", target: nil, action: nil) + closeButton.bezelStyle = .shadowlessSquare + closeButton.isBordered = false + closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") + closeButton.imagePosition = .imageOnly + closeButton.contentTintColor = PermissionPalette.secondaryText + closeButton.controlSize = .small + closeButton.translatesAutoresizingMaskIntoConstraints = false + let target = ButtonTarget(close) + closeButton.target = target + closeButton.action = #selector(ButtonTarget.run) + objc_setAssociatedObject(closeButton, "orca-action", target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + let instruction = label(permission.dragInstruction, size: 12, weight: .semibold) + instruction.textColor = PermissionPalette.primaryText + instruction.maximumNumberOfLines = 2 + + let dragTile = DraggableAppTile(appURL: appURL) + dragTile.translatesAutoresizingMaskIntoConstraints = false + + addSubview(closeButton) + addSubview(instruction) + addSubview(dragTile) + + NSLayoutConstraint.activate([ + closeButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + closeButton.topAnchor.constraint(equalTo: topAnchor, constant: 10), + closeButton.widthAnchor.constraint(equalToConstant: 18), + closeButton.heightAnchor.constraint(equalToConstant: 18), + instruction.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 36), + instruction.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + instruction.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), + dragTile.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + dragTile.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + dragTile.topAnchor.constraint(equalTo: instruction.bottomAnchor, constant: 8), + dragTile.heightAnchor.constraint(equalToConstant: 42) + ]) + } + + private func label(_ text: String, size: CGFloat, weight: NSFont.Weight) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = NSFont.systemFont(ofSize: size, weight: weight) + label.lineBreakMode = .byWordWrapping + label.textColor = PermissionPalette.primaryText + label.translatesAutoresizingMaskIntoConstraints = false + return label + } +} + +private final class DraggableAppTile: NSView, NSDraggingSource { + private let appURL: URL + + override var mouseDownCanMoveWindow: Bool { false } + + init(appURL: URL) { + self.appURL = appURL + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 12 + layer?.borderWidth = 1 + layer?.borderColor = PermissionPalette.border.cgColor + layer?.backgroundColor = PermissionPalette.card.cgColor + build() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func mouseDragged(with event: NSEvent) { + let item = NSPasteboardItem() + item.setString(appURL.absoluteString, forType: .fileURL) + item.setString(appURL.path, forType: .string) + + let draggingItem = NSDraggingItem(pasteboardWriter: item) + let iconSize: CGFloat = 64 + let location = convert(event.locationInWindow, from: nil) + let dragFrame = NSRect( + x: location.x - iconSize / 2, + y: location.y - iconSize / 2, + width: iconSize, + height: iconSize + ) + draggingItem.setDraggingFrame(dragFrame, contents: appIcon(size: iconSize)) + beginDraggingSession(with: [draggingItem], event: event, source: self) + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + .copy + } + + private func build() { + let icon = NSImageView(image: appIcon(size: 34)) + icon.imageScaling = .scaleProportionallyUpOrDown + icon.translatesAutoresizingMaskIntoConstraints = false + + let title = NSTextField(labelWithString: "Orca Computer Use") + title.font = NSFont.systemFont(ofSize: 15, weight: .semibold) + title.textColor = PermissionPalette.primaryText + title.translatesAutoresizingMaskIntoConstraints = false + + addSubview(icon) + addSubview(title) + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + icon.centerYAnchor.constraint(equalTo: centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 34), + icon.heightAnchor.constraint(equalToConstant: 34), + title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 14), + title.centerYAnchor.constraint(equalTo: centerYAnchor), + title.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -20) + ]) + } + + private func appIcon(size: CGFloat) -> NSImage { + let icon = NSWorkspace.shared.icon(forFile: appURL.path) + icon.size = NSSize(width: size, height: size) + return icon + } +} + +private enum PermissionPalette { + static let background = NSColor(calibratedWhite: 0.16, alpha: 0.98) + static let card = NSColor(calibratedWhite: 0.22, alpha: 0.98) + static let border = NSColor(calibratedWhite: 0.36, alpha: 0.9) + static let primaryText = NSColor(calibratedWhite: 0.94, alpha: 1) + static let secondaryText = NSColor(calibratedWhite: 0.66, alpha: 1) +} + +private final class ButtonTarget: NSObject { + private let actionBlock: () -> Void + + init(_ actionBlock: @escaping () -> Void) { + self.actionBlock = actionBlock + } + + @objc func run() { + actionBlock() + } +} + +private final class SocketListener: @unchecked Sendable { + private let socketPath: String + private let token: String? + private let provider = Provider() + private let providerLock = NSLock() + private var socketFd: Int32 = -1 + private var isStopped = false + + init(socketPath: String, token: String?) throws { + self.socketPath = socketPath + self.token = token + try bindSocket() + } + + func start() { + Thread.detachNewThread { [weak self] in + self?.acceptLoop() + } + } + + func stop() { + isStopped = true + if socketFd >= 0 { + close(socketFd) + socketFd = -1 + } + unlink(socketPath) + } + + private func bindSocket() throws { + unlink(socketPath) + socketFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFd >= 0 else { + throw ProviderError.coded("accessibility_error", "failed to create computer-use socket") + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) + guard socketPath.utf8.count < maxPathLength else { + throw ProviderError.coded("invalid_argument", "computer-use socket path is too long") + } + _ = withUnsafeMutablePointer(to: &address.sun_path) { pointer in + socketPath.withCString { source in + strncpy(UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CChar.self), source, maxPathLength) + } + } + + let result = withUnsafePointer(to: &address) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(socketFd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + let message = String(cString: strerror(errno)) + close(socketFd) + socketFd = -1 + throw ProviderError.coded("accessibility_error", "failed to bind computer-use socket: \(message)") + } + chmod(socketPath, 0o600) + + guard listen(socketFd, 8) == 0 else { + let message = String(cString: strerror(errno)) + close(socketFd) + socketFd = -1 + throw ProviderError.coded("accessibility_error", "failed to listen on computer-use socket: \(message)") + } + } + + private func acceptLoop() { + while !isStopped { + let fd = accept(socketFd, nil, nil) + if fd < 0 { + if !isStopped { + fputs("computer-use socket accept failed: \(String(cString: strerror(errno)))\n", stderr) + } + continue + } + Thread.detachNewThread { [weak self] in + self?.handleConnection(fd) + } + } + } + + private func handleConnection(_ fd: Int32) { + defer { close(fd) } + let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true + let decoder = JSONDecoder() + while let line = readLine(from: fd) { + guard let data = line.data(using: .utf8), + let request = try? decoder.decode(Request.self, from: data) + else { + continue + } + let response = handleRequest( + provider: provider, + lock: providerLock, + request: request, + expectedToken: token, + authorizedPeer: authorizedPeer + ) + writeJSON(response, to: fd) + } + } +} + +private func peerProcessId(_ fd: Int32) -> pid_t? { + var pid = pid_t(0) + var length = socklen_t(MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &pid) { pointer in + getsockopt(fd, 0, 2, pointer, &length) + } + return result == 0 && pid > 0 ? pid : nil +} + +private func isAuthorizedAgentPeer(_ pid: pid_t) -> Bool { + guard let command = processCommand(pid), + command.contains("/out/main/computer-sidecar.js") + || command.contains("/Contents/Resources/app.asar.unpacked/out/main/computer-sidecar.js") + else { + return false + } + if isTrustedOrcaApplication(pid) { + return true + } + guard let parentPid = parentProcessId(pid) else { return false } + return isTrustedOrcaApplication(parentPid) +} + +private func isTrustedOrcaApplication(_ pid: pid_t) -> Bool { + guard let app = NSRunningApplication(processIdentifier: pid), + let bundleId = app.bundleIdentifier + else { + return false + } + return bundleId == "com.stablyai.orca" || bundleId == "com.github.Electron" +} + +private func parentProcessId(_ pid: pid_t) -> pid_t? { + guard let output = processField(pid: pid, field: "ppid=") else { + return nil + } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard let parentPid = pid_t(trimmed), parentPid > 1 else { + return nil + } + return parentPid +} + +private func processCommand(_ pid: pid_t) -> String? { + return processField(pid: pid, field: "command=") +} + +private func processField(pid: pid_t, field: String) -> String? { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-p", "\(pid)", "-o", field] + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } catch { + return nil + } +} + +@MainActor +private func runAgent(socketPath: String, token: String?) { + let app = NSApplication.shared + let delegate = AgentRuntime(socketPath: socketPath, token: token) + app.delegate = delegate + // Why: SCK is reliable once this code runs as a signed app with a real TCC identity. + setenv("ORCA_COMPUTER_USE_SCK_SCREENSHOTS", "1", 1) + app.run() +} + +@MainActor +private func runPermissionCheck(initialPermission: PermissionKind? = nil) { + let app = NSApplication.shared + let delegate = PermissionRuntime(initialPermission: initialPermission) + app.delegate = delegate + // Why: setup must foreground reliably; the long-running agent path stays accessory-only. + app.setActivationPolicy(.regular) + app.run() +} + +private func printPermissionStatus() { + let accessibility = accessibilityTrusted() ? "granted" : "not-granted" + let screenshots = screenCaptureTrusted() ? "granted" : "not-granted" + print(#"{"accessibility":"\#(accessibility)","screenshots":"\#(screenshots)"}"#) +} + +private func writePermissionStatus(to path: String) { + let accessibility = accessibilityTrusted() ? "granted" : "not-granted" + let screenshots = screenCaptureTrusted() ? "granted" : "not-granted" + let text = #"{"accessibility":"\#(accessibility)","screenshots":"\#(screenshots)"}"# + do { + try text.write(toFile: path, atomically: true, encoding: .utf8) + } catch { + fputs("failed to write permission status: \(error)\n", stderr) + exit(1) + } +} + +private func runStdio() { + fputs("Orca Computer Use provider must be launched by Orca in app-agent mode.\n", stderr) + exit(13) +} + +private func handleRequest( + provider: Provider, + lock: NSLock, + request: Request, + expectedToken: String?, + authorizedPeer: Bool +) -> Any { + if let expectedToken, request.token != expectedToken { + return ["id": request.id, "ok": false, "error": ["code": "permission_denied", "message": "invalid computer-use agent token"]] + } + if expectedToken != nil && !authorizedPeer { + return ["id": request.id, "ok": false, "error": ["code": "permission_denied", "message": "computer-use agent peer is not authorized"]] + } + if request.method == "terminate" { + DispatchQueue.main.async { + NSApp.terminate(nil) + } + return ["id": request.id, "ok": true, "result": ["ok": true]] + } + + do { + lock.lock() + defer { lock.unlock() } + let result = try provider.handle(method: request.method, params: request.params ?? [:]) + return ["id": request.id, "ok": true, "result": result] + } catch let error as ProviderError { + return ["id": request.id, "ok": false, "error": ["code": error.code, "message": error.message]] + } catch { + return ["id": request.id, "ok": false, "error": ["code": "accessibility_error", "message": String(describing: error)]] + } +} + +private func readLine(from fd: Int32) -> String? { + var bytes: [UInt8] = [] + var byte: UInt8 = 0 + while true { + let count = read(fd, &byte, 1) + if count == 0 { + return bytes.isEmpty ? nil : String(bytes: bytes, encoding: .utf8) + } + if count < 0 { + return nil + } + if byte == 10 { + return String(bytes: bytes, encoding: .utf8) + } + bytes.append(byte) + } +} + +private func writeJSON(_ object: Any, to fd: Int32?) { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.withoutEscapingSlashes]) + else { + return + } + if let fd { + _ = writeAll(data, to: fd) + _ = writeAll(Data([10]), to: fd) + } else { + guard let text = String(data: data, encoding: .utf8) else { + return + } + print(text) + fflush(stdout) + } +} + +private func writeAll(_ data: Data, to fd: Int32) -> Bool { + data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { + return true + } + var offset = 0 + while offset < rawBuffer.count { + let written = write(fd, baseAddress.advanced(by: offset), rawBuffer.count - offset) + if written < 0 { + if errno == EINTR { + continue + } + return false + } + if written == 0 { + return false + } + offset += written + } + return true + } +} + +let arguments = Array(CommandLine.arguments.dropFirst()) +if arguments.first == "--agent" { + guard arguments.count >= 2 else { + fputs("usage: orca-computer-use-macos --agent --token-file \n", stderr) + exit(2) + } + let tokenFileIndex = arguments.firstIndex(of: "--token-file") + let token = tokenFileIndex.flatMap { index -> String? in + let valueIndex = index + 1 + guard valueIndex < arguments.count else { return nil } + let tokenPath = arguments[valueIndex] + defer { unlink(tokenPath) } + return try? String(contentsOfFile: tokenPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + guard let token, !token.isEmpty else { + fputs("orca-computer-use-macos --agent requires a non-empty --token-file\n", stderr) + exit(2) + } + runAgent(socketPath: arguments[1], token: token) +} else if arguments.first == "--permissions" { + runPermissionCheck() +} else if arguments.first == "--permission" { + runPermissionCheck(initialPermission: PermissionKind.parse(arguments.dropFirst().first)) +} else if arguments.first == "--permission-status" { + printPermissionStatus() +} else if arguments.first == "--permission-status-file" { + guard arguments.count >= 2 else { + fputs("usage: orca-computer-use-macos --permission-status-file \n", stderr) + exit(2) + } + writePermissionStatus(to: arguments[1]) +} else { + runStdio() +} diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift new file mode 100644 index 00000000000..b5299cbf54b --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift @@ -0,0 +1,236 @@ +import Foundation + +public struct SnapshotRenderNode: Equatable { + public var role: String + public var roleDescription: String? + public var title: String? + public var label: String? + public var linkText: String? + public var value: String? + public var placeholder: String? + public var url: String? + public var traits: [String] + public var rawActions: [String] + public var childCount: Int + public var summary: String? + public var rowSummary: String? + public var webAreaDepth: Int? + + public init( + role: String, + roleDescription: String? = nil, + title: String? = nil, + label: String? = nil, + linkText: String? = nil, + value: String? = nil, + placeholder: String? = nil, + url: String? = nil, + traits: [String] = [], + rawActions: [String] = [], + childCount: Int = 0, + summary: String? = nil, + rowSummary: String? = nil, + webAreaDepth: Int? = nil + ) { + self.role = role + self.roleDescription = roleDescription + self.title = title + self.label = label + self.linkText = linkText + self.value = value + self.placeholder = placeholder + self.url = url + self.traits = traits + self.rawActions = rawActions + self.childCount = childCount + self.summary = summary + self.rowSummary = rowSummary + self.webAreaDepth = webAreaDepth + } +} + +public enum SnapshotRenderHeuristics { + public static func displayName(_ node: SnapshotRenderNode) -> String? { + if let title = clean(node.title) { + return title + } + if node.role == "AXLink", let url = clean(node.url), let text = clean(node.linkText ?? node.label ?? node.value) { + return "[\(markdownEscaped(text))](\(url))" + } + if node.role == "AXWebArea" { + return clean(node.label) ?? clean(node.value) + } + if ["AXButton", "AXPopUpButton", "AXImage"].contains(node.role) { + return clean(node.label) + } + if ["AXRow", "AXCell", "AXOutlineRow"].contains(node.role) { + return clean(node.rowSummary) + } + return clean(node.label) + } + + public static func meaningfulActions(_ rawActions: [String], role: String) -> [String] { + let noisy: Set = [ + "AXPress", + "AXShowDefaultUI", + "AXShowAlternateUI", + "AXShowMenu", + "AXScrollToVisible", + "AXConfirm", + "AXRaise", + ] + return rawActions.filter { action in + if noisy.contains(action) { return false } + if role == "AXMenu" || role == "AXMenuItem" { + return action != "AXCancel" && action != "AXPick" + } + if role == "AXScrollArea", + (rawActions.contains("AXScrollUpByPage") || rawActions.contains("AXScrollDownByPage")), + action == "AXScrollLeftByPage" || action == "AXScrollRightByPage" { + return false + } + return true + } + } + + public static func shouldElide(_ node: SnapshotRenderNode) -> Bool { + guard node.role == "AXGroup" || node.role == "AXUnknown" else { return false } + guard displayName(node) == nil, + node.traits.isEmpty, + meaningfulActions(node.rawActions, role: node.role).isEmpty, + clean(node.summary) == nil + else { + return false + } + if node.webAreaDepth != nil, node.childCount > 1 { + return false + } + return true + } + + public static func shouldSuppressChildren(_ node: SnapshotRenderNode) -> Bool { + if node.role == "AXMenuBarItem" { + return true + } + let name = displayName(node) + if node.role == "AXLink" && name?.hasPrefix("[") == true { + return true + } + let hasCompactLabel = name != nil || clean(node.value) != nil || clean(node.summary) != nil + return hasCompactLabel && compactControlRoles.contains(node.role) + } + + public static func shouldSuppressChildren(role: String, name: String? = nil) -> Bool { + shouldSuppressChildren(SnapshotRenderNode(role: role, title: name)) + } + + public static func line(index: Int, node: SnapshotRenderNode) -> String { + let name = displayName(node) + let roleText = roleText(node) + let meaningful = meaningfulActions(node.rawActions, role: node.role) + var line = roleText.isEmpty ? "\(index)" : "\(index) \(roleText)" + if !node.traits.isEmpty { line += " (\(node.traits.joined(separator: ", ")))" } + if let name { line += " \(sanitize(name))" } + if node.role != "AXLink", let description = clean(node.label), description != name { + line += ", Description: \(sanitize(description))" + } + if let valueSegment = formattedValueSegment(roleText: roleText, name: name, value: node.value) { + line += valueSegment + } + if let placeholder = clean(node.placeholder), placeholder != name, placeholder != node.value { + line += name == nil && clean(node.value) == nil ? " Placeholder: \(sanitize(placeholder))" : ", Placeholder: \(sanitize(placeholder))" + } + if let summary = clean(node.summary), summary != name { + line += ", Text: \(sanitize(summary))" + } else if let rowSummary = clean(node.rowSummary), rowSummary != name { + line += ", Text: \(sanitize(rowSummary))" + } + if !meaningful.isEmpty { + line += ", Secondary Actions: \(meaningful.map(prettyAction).joined(separator: ", "))" + } + return line + } + + public static func roleText(_ node: SnapshotRenderNode) -> String { + if node.role == "AXGroup" || node.role == "AXUnknown" { + return "container" + } + if node.role == "AXLink" { + return "link" + } + if node.role == "AXWebArea" { + return clean(node.roleDescription) ?? "html content" + } + if node.role == "AXMenuBarItem" { + return "" + } + if let value = clean(node.roleDescription) { + return value.lowercased() + } + if node.role.hasPrefix("AX") { + return splitCamelCase(String(node.role.dropFirst(2))).lowercased() + } + return node.role + } + + public static func prettyAction(_ action: String) -> String { + if action == "AXZoomWindow" { + return "zoom the window" + } + let stripped = action.hasPrefix("AX") ? String(action.dropFirst(2)) : action + return splitCamelCase(stripped.replacingOccurrences(of: "ByPage", with: "")).lowercased() + } + + public static func sanitize(_ value: String) -> String { + value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ") + } + + private static func clean(_ value: String?) -> String? { + guard let value else { return nil } + let sanitized = sanitize(value) + return sanitized.isEmpty ? nil : sanitized + } + + private static func formattedValueSegment(roleText: String, name: String?, value: String?) -> String? { + guard let value = clean(value), value != name else { return nil } + if roleText == "heading", Int(value) != nil { + return nil + } + let clean = sanitize(value) + if roleText == "text" || roleText == "text entry area" || roleText == "scroll bar" || roleText == "value indicator" { + return " \(clean)" + } + return ", Value: \(clean)" + } + + private static func markdownEscaped(_ value: String) -> String { + sanitize(value) + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "[", with: "\\[") + .replacingOccurrences(of: "]", with: "\\]") + } + + private static func splitCamelCase(_ value: String) -> String { + var result = "" + for character in value { + if character.isUppercase, !result.isEmpty { + result.append(" ") + } + result.append(character) + } + return result + } + + private static let compactControlRoles: Set = [ + "AXButton", + "AXCheckBox", + "AXComboBox", + "AXDisclosureTriangle", + "AXHeading", + "AXMenuItem", + "AXPopUpButton", + "AXRadioButton", + "AXStaticText", + "AXTab", + ] +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift new file mode 100644 index 00000000000..3c28e044901 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import OrcaComputerUseMacOSCore + +final class SnapshotRenderingTests: XCTestCase { + func testElidesAnonymousWrappers() { + let node = SnapshotRenderNode(role: "AXGroup", childCount: 1) + + XCTAssertTrue(SnapshotRenderHeuristics.shouldElide(node)) + } + + func testPreservesWebAreaContainersWithMultipleChildren() { + let node = SnapshotRenderNode(role: "AXGroup", childCount: 3, webAreaDepth: 1) + + XCTAssertFalse(SnapshotRenderHeuristics.shouldElide(node)) + } + + func testRendersMarkdownLinksAndSuppressesTheirChildren() { + let node = SnapshotRenderNode(role: "AXLink", linkText: "Skip [main]", url: "https://example.com/path") + + XCTAssertEqual(SnapshotRenderHeuristics.line(index: 7, node: node), "7 link [Skip \\[main\\]](https://example.com/path)") + XCTAssertTrue(SnapshotRenderHeuristics.shouldSuppressChildren(node)) + } + + func testSuppressesChildrenForNamedCompactControls() { + let button = SnapshotRenderNode(role: "AXButton", roleDescription: "button", label: "Install GitHub", childCount: 1) + let heading = SnapshotRenderNode(role: "AXHeading", roleDescription: "heading", label: "Repository navigation", value: "2", childCount: 1) + + XCTAssertTrue(SnapshotRenderHeuristics.shouldSuppressChildren(button)) + XCTAssertTrue(SnapshotRenderHeuristics.shouldSuppressChildren(heading)) + XCTAssertEqual(SnapshotRenderHeuristics.line(index: 5, node: heading), "5 heading Repository navigation") + } + + func testKeepsChildrenForRowsWithNestedControls() { + let row = SnapshotRenderNode(role: "AXRow", roleDescription: "row", childCount: 3, rowSummary: "Liked Songs") + + XCTAssertFalse(SnapshotRenderHeuristics.shouldSuppressChildren(row)) + } + + func testFiltersNoisyActionsAndFormatsSecondaryActions() { + let node = SnapshotRenderNode( + role: "AXWindow", + title: "Document", + rawActions: ["AXPress", "AXShowMenu", "AXScrollToVisible", "AXZoomWindow"] + ) + + XCTAssertEqual(SnapshotRenderHeuristics.meaningfulActions(node.rawActions, role: node.role), ["AXZoomWindow"]) + XCTAssertEqual(SnapshotRenderHeuristics.line(index: 1, node: node), "1 window Document, Secondary Actions: zoom the window") + } + + func testSuppressesHorizontalScrollWhenVerticalScrollExists() { + let node = SnapshotRenderNode( + role: "AXScrollArea", + rawActions: ["AXScrollUpByPage", "AXScrollDownByPage", "AXScrollLeftByPage", "AXScrollRightByPage"] + ) + + XCTAssertEqual(SnapshotRenderHeuristics.meaningfulActions(node.rawActions, role: node.role), ["AXScrollUpByPage", "AXScrollDownByPage"]) + } + + func testTextFieldsKeepDistinctValueAndPlaceholder() { + let node = SnapshotRenderNode( + role: "AXTextField", + roleDescription: "text field", + label: "Address", + value: "https://example.com", + placeholder: "Search" + ) + + XCTAssertEqual( + SnapshotRenderHeuristics.line(index: 3, node: node), + "3 text field Address, Value: https://example.com, Placeholder: Search" + ) + } + + func testStaticTextUsesCompactValueFormatting() { + let node = SnapshotRenderNode(role: "AXStaticText", roleDescription: "text", value: "Home") + + XCTAssertEqual(SnapshotRenderHeuristics.line(index: 4, node: node), "4 text Home") + } + + func testRowSummaryBecomesName() { + let node = SnapshotRenderNode(role: "AXRow", roleDescription: "row", rowSummary: "General Settings Enabled") + + XCTAssertEqual(SnapshotRenderHeuristics.line(index: 9, node: node), "9 row General Settings Enabled") + } +} diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 new file mode 100644 index 00000000000..f87daa87e5c --- /dev/null +++ b/native/computer-use-windows/runtime.ps1 @@ -0,0 +1,879 @@ +param( + [Parameter(Mandatory = $true)] + [string]$OperationPath +) + +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms + +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; + +public static class OrcaDesktopWin32 { + [StructLayout(LayoutKind.Sequential)] + public struct RECT { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + public struct POINT { + public int X; + public int Y; + } + + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect); + + [DllImport("user32.dll")] + public static extern bool ScreenToClient(IntPtr hwnd, ref POINT point); + + [DllImport("user32.dll")] + public static extern bool PostMessage(IntPtr hwnd, uint message, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hwnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hwnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); +} +"@ + +$MaxNodes = 1200 +$MaxDepth = 64 +$TextLimit = 500 +$BlockedAppFragments = @( + "1password", + "bitwarden", + "dashlane", + "lastpass", + "nordpass", + "proton pass" +) + +$WindowsMessages = @{ + Char = 0x0102 + KeyDown = 0x0100 + KeyUp = 0x0101 + MouseMove = 0x0200 + LeftDown = 0x0201 + LeftUp = 0x0202 + RightDown = 0x0204 + RightUp = 0x0205 + MiddleDown = 0x0207 + MiddleUp = 0x0208 + Wheel = 0x020A +} + +function Write-OrcaJson($Payload) { + $Payload | ConvertTo-Json -Depth 100 -Compress +} + +function New-OrcaFrame([double]$X, [double]$Y, [double]$Width, [double]$Height) { + if ($Width -le 0 -or $Height -le 0) { return $null } + [pscustomobject]@{ x = $X; y = $Y; width = $Width; height = $Height } +} + +function Read-OrcaOperation([string]$Path) { + Get-Content -Raw -Path $Path | ConvertFrom-Json +} + +function ConvertTo-OrcaLParam([int]$X, [int]$Y) { + [IntPtr]((($Y -band 0xffff) -shl 16) -bor ($X -band 0xffff)) +} + +function ConvertTo-OrcaWheelParam([int]$Delta) { + [IntPtr](($Delta -band 0xffff) -shl 16) +} + +function Get-OrcaWindowProcesses { + @(Get-Process | Where-Object { $_.MainWindowHandle -ne 0 } | Sort-Object ProcessName, Id) +} + +function Find-OrcaProcess([string]$Query) { + $needle = "" + if ($null -ne $Query) { $needle = $Query.Trim() } + if ([string]::IsNullOrWhiteSpace($needle)) { throw 'appNotFound("")' } + if ($needle.StartsWith("pid:", [System.StringComparison]::OrdinalIgnoreCase)) { + $needle = $needle.Substring(4) + } + + $pid = 0 + $processes = Get-OrcaWindowProcesses + if ([int]::TryParse($needle, [ref]$pid)) { + $match = $processes | Where-Object { $_.Id -eq $pid } | Select-Object -First 1 + if ($null -ne $match) { + Assert-OrcaProcessAllowed $match + return $match + } + } + + $processNeedle = $needle + if ($processNeedle.EndsWith(".exe", [System.StringComparison]::OrdinalIgnoreCase)) { + $processNeedle = $processNeedle.Substring(0, $processNeedle.Length - 4) + } + + $match = $processes | Where-Object { + $_.ProcessName -ieq $processNeedle -or + "$($_.ProcessName).exe" -ieq $needle -or + $_.MainWindowTitle -ieq $needle -or + $_.MainWindowTitle -ilike "*$needle*" + } | Select-Object -First 1 + if ($null -ne $match) { + Assert-OrcaProcessAllowed $match + return $match + } + + throw "appNotFound(`"$Query`")" +} + +function Assert-OrcaProcessAllowed($Process) { + $values = @($Process.ProcessName, $Process.MainWindowTitle) | ForEach-Object { ([string]$_).ToLowerInvariant() } + foreach ($fragment in $BlockedAppFragments) { + foreach ($value in $values) { + if ($value.Contains($fragment)) { + throw "appBlocked(`"$($Process.ProcessName)`")" + } + } + } +} + +function Get-OrcaRootElement($Process) { + if ($Process.MainWindowHandle -eq 0) { + throw "No top-level UI Automation window is available for $($Process.ProcessName)." + } + [Windows.Automation.AutomationElement]::FromHandle([IntPtr]$Process.MainWindowHandle) +} + +function Get-OrcaWindowFrame($Process, $RootElement) { + $rect = New-Object OrcaDesktopWin32+RECT + if ([OrcaDesktopWin32]::GetWindowRect([IntPtr]$Process.MainWindowHandle, [ref]$rect)) { + return New-OrcaFrame $rect.Left $rect.Top ($rect.Right - $rect.Left) ($rect.Bottom - $rect.Top) + } + + try { + $bounds = $RootElement.Current.BoundingRectangle + if (-not $bounds.IsEmpty) { + return New-OrcaFrame $bounds.X $bounds.Y $bounds.Width $bounds.Height + } + } catch {} + $null +} + +function Get-OrcaWindowId($Process) { + [int64]$Process.MainWindowHandle +} + +function Assert-OrcaWindowTarget($Process, $WindowId, $WindowIndex) { + if ($null -ne $WindowIndex -and [int]$WindowIndex -ne 0) { + throw "windowNotFound(`"$WindowIndex`")" + } + if ($null -ne $WindowId -and [int64]$WindowId -ne (Get-OrcaWindowId $Process)) { + throw "windowNotFound(`"$WindowId`")" + } +} + +function Restore-OrcaWindow($Process) { + if ($Process.MainWindowHandle -eq 0) { return } + [void][OrcaDesktopWin32]::ShowWindow([IntPtr]$Process.MainWindowHandle, 9) + [void][OrcaDesktopWin32]::SetForegroundWindow([IntPtr]$Process.MainWindowHandle) +} + +function Assert-OrcaKeyboardFocus([IntPtr]$WindowHandle, $Operation) { + if ([bool]$Operation.restoreWindow) { return } + if ([OrcaDesktopWin32]::GetForegroundWindow() -eq $WindowHandle) { return } + throw "window_not_focused: keyboard input requires the target window to be focused; retry with --restore-window" +} + +function Get-OrcaElementFrame($Element, $WindowFrame) { + try { + $bounds = $Element.Current.BoundingRectangle + if ($bounds.IsEmpty) { return $null } + if ($null -eq $WindowFrame) { + return New-OrcaFrame $bounds.X $bounds.Y $bounds.Width $bounds.Height + } + New-OrcaFrame ($bounds.X - $WindowFrame.x) ($bounds.Y - $WindowFrame.y) $bounds.Width $bounds.Height + } catch { + $null + } +} + +function Get-OrcaProperty($Element, [string]$Name) { + try { [string]$Element.Current.$Name } catch { "" } +} + +function Get-OrcaRuntimeId($Element) { + try { @($Element.GetRuntimeId()) } catch { @() } +} + +function Get-OrcaValueText($Element) { + try { + if ($Element.Current.IsPassword) { return "[redacted]" } + $pattern = $Element.GetCurrentPattern([Windows.Automation.ValuePattern]::Pattern) + $rawValue = $pattern.Current.Value + $text = if ($null -eq $rawValue) { "" } else { [string]$rawValue } + if ($text.Length -gt $TextLimit) { return $text.Substring(0, $TextLimit) + "..." } + $text + } catch { + "" + } +} + +function Get-OrcaActions($Element) { + $actions = New-Object System.Collections.Generic.List[string] + foreach ($pattern in $Element.GetSupportedPatterns()) { + $name = [string]$pattern.ProgrammaticName + if ($name -like "InvokePatternIdentifiers.Pattern") { $actions.Add("Invoke") } + elseif ($name -like "TogglePatternIdentifiers.Pattern") { $actions.Add("Toggle") } + elseif ($name -like "SelectionItemPatternIdentifiers.Pattern") { $actions.Add("Select") } + elseif ($name -like "ScrollPatternIdentifiers.Pattern") { $actions.Add("Scroll") } + elseif ($name -like "ValuePatternIdentifiers.Pattern") { $actions.Add("SetValue") } + } + @($actions | Select-Object -Unique) +} + +function Get-OrcaMeaningfulActions($Actions) { + $noisy = @("Invoke", "ScrollToVisible", "ShowMenu") + @($Actions | Where-Object { $noisy -notcontains $_ }) +} + +function Format-OrcaSnapshotText([string]$Text) { + if ([string]::IsNullOrWhiteSpace($Text)) { return "" } + (($Text -replace "\s+", " ").Trim()) +} + +function Format-OrcaValueSegment([string]$RoleKey, [string]$Title, [string]$Value) { + $clean = Format-OrcaSnapshotText $Value + if ([string]::IsNullOrWhiteSpace($clean) -or $clean -eq $Title) { return "" } + if ($RoleKey -eq "heading" -and $clean -match "^\d+$") { return "" } + if ($RoleKey -in @("text", "edit", "document", "scroll bar", "progress bar")) { + return " $clean" + } + ", Value: $clean" +} + +function Test-OrcaSuppressChildren([string]$RoleKey, [string]$Title, [string]$Value, [string]$Summary) { + $hasCompactLabel = -not [string]::IsNullOrWhiteSpace($Title) -or -not [string]::IsNullOrWhiteSpace((Format-OrcaSnapshotText $Value)) -or -not [string]::IsNullOrWhiteSpace((Format-OrcaSnapshotText $Summary)) + $hasCompactLabel -and $RoleKey -in @( + "button", + "check box", + "combo box", + "heading", + "hyperlink", + "link", + "menu item", + "radio button", + "tab item" + ) +} + +function Get-OrcaTextSnippets($Element, [int]$Limit = 6, [int]$MaxDepth = 3) { + $values = New-Object System.Collections.Generic.List[string] + $seen = New-Object System.Collections.Generic.HashSet[string] + + function Visit-OrcaText($Node, [int]$Depth) { + if ($values.Count -ge $Limit -or $Depth -gt $MaxDepth) { return } + $role = try { [string]$Node.Current.LocalizedControlType } catch { "" } + if ($role -match "text|link|label") { + foreach ($raw in @((Get-OrcaProperty $Node "Name"), (Get-OrcaValueText $Node))) { + $value = (($raw -replace "\s+", " ").Trim()) + if (-not [string]::IsNullOrWhiteSpace($value) -and $seen.Add($value)) { + if ($value.Length -gt 80) { $value = $value.Substring(0, 80) + "..." } + $values.Add($value) + if ($values.Count -ge $Limit) { return } + } + } + } + try { + $children = $Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition) + for ($i = 0; $i -lt $children.Count; $i++) { + Visit-OrcaText $children.Item($i) ($Depth + 1) + if ($values.Count -ge $Limit) { return } + } + } catch {} + } + + Visit-OrcaText $Element 0 + @($values.ToArray()) +} + +function Test-OrcaPlainTextSubtree($Element, [int]$MaxDepth = 4) { + $script:sawOrcaText = $false + $allowed = @("pane", "group", "custom", "unknown", "text", "link", "image") + + function Visit-OrcaPlainText($Node, [int]$Depth) { + if ($Depth -gt $MaxDepth) { return $false } + $role = try { [string]$Node.Current.LocalizedControlType } catch { "" } + $roleKey = $role.ToLowerInvariant() + if ($allowed -notcontains $roleKey) { return $false } + if ($roleKey -match "text|link") { $script:sawOrcaText = $true } + if (@(Get-OrcaMeaningfulActions @(Get-OrcaActions $Node)).Count -gt 0) { return $false } + try { + $children = $Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition) + for ($i = 0; $i -lt $children.Count; $i++) { + if (-not (Visit-OrcaPlainText $children.Item($i) ($Depth + 1))) { return $false } + } + } catch {} + return $true + } + + (Visit-OrcaPlainText $Element 0) -and $script:sawOrcaText +} + +function New-OrcaElementRecord($Element, [int]$Index, $WindowFrame) { + $controlType = try { [string]$Element.Current.ControlType.ProgrammaticName } catch { "" } + $nativeWindowHandle = try { [int64]$Element.Current.NativeWindowHandle } catch { 0 } + [pscustomobject]@{ + index = $Index + runtimeId = @(Get-OrcaRuntimeId $Element) + automationId = Get-OrcaProperty $Element "AutomationId" + name = Get-OrcaProperty $Element "Name" + controlType = $controlType + localizedControlType = Get-OrcaProperty $Element "LocalizedControlType" + className = Get-OrcaProperty $Element "ClassName" + value = Get-OrcaValueText $Element + nativeWindowHandle = $nativeWindowHandle + frame = Get-OrcaElementFrame $Element $WindowFrame + actions = @(Get-OrcaActions $Element) + } +} + +function Render-OrcaTree($RootElement, $WindowFrame) { + $records = New-Object System.Collections.Generic.List[object] + $lines = New-Object System.Collections.Generic.List[string] + $seen = New-Object System.Collections.Generic.HashSet[string] + $truncation = [pscustomobject]@{ + truncated = $false + maxNodes = $MaxNodes + maxDepth = $MaxDepth + maxDepthReached = $false + } + + function Visit-OrcaNode($Node, [int]$Depth) { + if ($records.Count -ge $MaxNodes -or $Depth -gt $MaxDepth) { + $truncation.truncated = $true + if ($Depth -gt $MaxDepth) { $truncation.maxDepthReached = $true } + return + } + $identity = try { (@($Node.GetRuntimeId()) -join ".") } catch { [Guid]::NewGuid().ToString() } + if (-not $seen.Add($identity)) { return } + + $record = New-OrcaElementRecord $Node $records.Count $WindowFrame + $children = @() + try { + $children = @($Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition)) + } catch {} + $meaningfulActions = @(Get-OrcaMeaningfulActions $record.actions) + $title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name } + $role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType } + $roleKey = $role.ToLowerInvariant() + $snippets = @(Get-OrcaTextSnippets $Node 8 4) + $genericSummary = $null + if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { + $genericSummary = ($snippets -join " ") + } + if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) { + for ($i = 0; $i -lt $children.Count; $i++) { + Visit-OrcaNode $children.Item($i) $Depth + } + return + } + + $records.Add($record) + + $line = "$($record.index) $role $(Format-OrcaSnapshotText $title)".TrimEnd() + $line += Format-OrcaValueSegment $roleKey $title $record.value + if (-not [string]::IsNullOrWhiteSpace($genericSummary) -and $genericSummary -ne $title) { + $line += ", Text: " + (Format-OrcaSnapshotText $genericSummary) + } elseif ($roleKey -in @("row", "data item", "list item")) { + $rowSummary = @((Get-OrcaTextSnippets $Node 6 3)) -join " " + if (-not [string]::IsNullOrWhiteSpace($rowSummary) -and $rowSummary -ne $title) { + $line += ", Text: " + (Format-OrcaSnapshotText $rowSummary) + } + } + if ($meaningfulActions.Count -gt 0) { + $line += ", Secondary Actions: " + ($meaningfulActions -join ", ") + } + $lines.Add(("`t" * $Depth) + $line) + + if (-not [string]::IsNullOrWhiteSpace($genericSummary) -or (Test-OrcaSuppressChildren $roleKey $title $record.value $genericSummary)) { return } + for ($i = 0; $i -lt $children.Count; $i++) { + Visit-OrcaNode $children.Item($i) ($Depth + 1) + } + } + + Visit-OrcaNode $RootElement 0 + [pscustomobject]@{ elements = @($records.ToArray()); lines = @($lines.ToArray()); truncation = $truncation } +} + +function Get-OrcaScreenshot([bool]$IncludeScreenshot, $WindowFrame) { + if (-not $IncludeScreenshot -or $null -eq $WindowFrame) { return $null } + try { + $width = [int][Math]::Max(1, [Math]::Round($WindowFrame.width)) + $height = [int][Math]::Max(1, [Math]::Round($WindowFrame.height)) + $bitmap = New-Object System.Drawing.Bitmap $width, $height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen([int][Math]::Round($WindowFrame.x), [int][Math]::Round($WindowFrame.y), 0, 0, $bitmap.Size) + $stream = New-Object System.IO.MemoryStream + $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) + $bytes = $stream.ToArray() + $graphics.Dispose() + $bitmap.Dispose() + $stream.Dispose() + [Convert]::ToBase64String($bytes) + } catch { + $null + } +} + +function New-OrcaSnapshot([string]$Query, [bool]$IncludeScreenshot, $WindowId = $null, $WindowIndex = $null, [bool]$RestoreWindow = $false) { + $process = Find-OrcaProcess $Query + if ($RestoreWindow) { Restore-OrcaWindow $process } + Assert-OrcaWindowTarget $process $WindowId $WindowIndex + $root = Get-OrcaRootElement $process + $windowFrame = Get-OrcaWindowFrame $process $root + $tree = Render-OrcaTree $root $windowFrame + + [pscustomobject]@{ + snapshotId = [guid]::NewGuid().ToString() + app = [pscustomobject]@{ + name = $process.ProcessName + bundleIdentifier = $process.ProcessName + bundleId = $process.ProcessName + pid = [int]$process.Id + } + windowTitle = $process.MainWindowTitle + windowId = Get-OrcaWindowId $process + windowBounds = $windowFrame + screenshotPngBase64 = Get-OrcaScreenshot $IncludeScreenshot $windowFrame + coordinateSpace = "window" + truncation = $tree.truncation + treeLines = @($tree.lines) + focusedSummary = $null + focusedElementId = $null + selectedText = $null + elements = @($tree.elements) + } +} + +function Get-OrcaAppList { + @(Get-OrcaWindowProcesses | ForEach-Object { + [pscustomobject]@{ + name = $_.ProcessName + bundleIdentifier = $_.ProcessName + bundleId = $_.ProcessName + pid = [int]$_.Id + } + }) +} + +function Get-OrcaWindowList([string]$Query) { + $process = Find-OrcaProcess $Query + $root = Get-OrcaRootElement $process + $windowFrame = Get-OrcaWindowFrame $process $root + $x = $null + $y = $null + $width = 0 + $height = 0 + if ($null -ne $windowFrame) { + $x = [int][Math]::Round($windowFrame.x) + $y = [int][Math]::Round($windowFrame.y) + $width = [int][Math]::Max(0, [Math]::Round($windowFrame.width)) + $height = [int][Math]::Max(0, [Math]::Round($windowFrame.height)) + } + $app = [pscustomobject]@{ + name = $process.ProcessName + bundleIdentifier = $process.ProcessName + bundleId = $process.ProcessName + pid = [int]$process.Id + } + [pscustomobject]@{ + app = $app + windows = @([pscustomobject]@{ + index = 0 + app = $app + id = Get-OrcaWindowId $process + title = $process.MainWindowTitle + x = $x + y = $y + width = $width + height = $height + isMinimized = $false + isOffscreen = $false + screenIndex = $null + platform = [pscustomobject]@{ backend = "uia"; nativeWindowHandle = Get-OrcaWindowId $process } + }) + } +} + +function Get-OrcaHandshake { + [pscustomobject]@{ + platform = "win32" + provider = "orca-computer-use-windows" + providerVersion = "1.0.0" + protocolVersion = 1 + supports = [pscustomobject]@{ + apps = [pscustomobject]@{ list = $true; bundleIds = $false; pids = $true } + windows = [pscustomobject]@{ list = $true; targetById = $true; targetByIndex = $true; focus = $false; moveResize = $false } + observation = [pscustomobject]@{ screenshot = $true; annotatedScreenshot = $false; elementFrames = $true; ocr = $false } + actions = [pscustomobject]@{ + click = $true + typeText = $true + pressKey = $true + hotkey = $true + pasteText = $true + scroll = $true + drag = $true + setValue = $true + performAction = $true + } + surfaces = [pscustomobject]@{ menus = $false; dialogs = $false; dock = $false; menubar = $false } + } + } +} + +function Test-OrcaSameRuntimeId($Left, $Right) { + if ($null -eq $Left -or $null -eq $Right -or $Left.Count -ne $Right.Count) { return $false } + for ($i = 0; $i -lt $Left.Count; $i++) { + if ([int]$Left[$i] -ne [int]$Right[$i]) { return $false } + } + $true +} + +function Find-OrcaElement($RootElement, $Record) { + if ($null -eq $Record) { return $null } + if ($Record.index -eq 0) { return $RootElement } + + try { + $descendants = $RootElement.FindAll([Windows.Automation.TreeScope]::Descendants, [Windows.Automation.Condition]::TrueCondition) + for ($i = 0; $i -lt $descendants.Count; $i++) { + $candidate = $descendants.Item($i) + if (Test-OrcaSameRuntimeId @($candidate.GetRuntimeId()) @($Record.runtimeId)) { + return $candidate + } + } + } catch {} + $null +} + +function Invoke-OrcaPrimaryAction($Element) { + foreach ($pattern in @( + [Windows.Automation.InvokePattern]::Pattern, + [Windows.Automation.SelectionItemPattern]::Pattern, + [Windows.Automation.TogglePattern]::Pattern + )) { + try { + $instance = $Element.GetCurrentPattern($pattern) + if ($pattern -eq [Windows.Automation.InvokePattern]::Pattern) { $instance.Invoke(); return $true } + if ($pattern -eq [Windows.Automation.SelectionItemPattern]::Pattern) { $instance.Select(); return $true } + if ($pattern -eq [Windows.Automation.TogglePattern]::Pattern) { $instance.Toggle(); return $true } + } catch {} + } + $false +} + +function Invoke-OrcaNamedAction($Element, [string]$Action) { + $wanted = "" + if ($null -ne $Action) { $wanted = $Action.Trim().ToLowerInvariant() } + switch ($wanted) { + "invoke" { + $pattern = $Element.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern) + $pattern.Invoke() + return $true + } + "select" { + $pattern = $Element.GetCurrentPattern([Windows.Automation.SelectionItemPattern]::Pattern) + $pattern.Select() + return $true + } + "toggle" { + $pattern = $Element.GetCurrentPattern([Windows.Automation.TogglePattern]::Pattern) + $pattern.Toggle() + return $true + } + default { + return $false + } + } +} + +function Set-OrcaElementValue($Element, [string]$Value) { + try { + $pattern = $Element.GetCurrentPattern([Windows.Automation.ValuePattern]::Pattern) + if (-not $pattern.Current.IsReadOnly) { + $pattern.SetValue($Value) + return $true + } + } catch {} + $false +} + +function Get-OrcaScreenPoint($Operation, $WindowFrame) { + if ($null -ne $Operation.element) { + throw "stale element frame; run get-app-state again and use a fresh element index" + } + @{ + x = [int][Math]::Round($WindowFrame.x + [double]$Operation.x) + y = [int][Math]::Round($WindowFrame.y + [double]$Operation.y) + } +} + +function Get-OrcaElementScreenPoint($Element) { + if ($null -eq $Element) { return $null } + try { + $rect = $Element.Current.BoundingRectangle + if ($rect.Width -gt 0 -and $rect.Height -gt 0) { + return @{ + x = [int][Math]::Round($rect.X + ($rect.Width / 2)) + y = [int][Math]::Round($rect.Y + ($rect.Height / 2)) + } + } + } catch {} + $null +} + +function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count) { + $point = New-Object OrcaDesktopWin32+POINT + $point.X = $ScreenX + $point.Y = $ScreenY + [void][OrcaDesktopWin32]::ScreenToClient($WindowHandle, [ref]$point) + + $down = $WindowsMessages.LeftDown + $up = $WindowsMessages.LeftUp + $flag = 1 + if ($Button -eq "right") { + $down = $WindowsMessages.RightDown + $up = $WindowsMessages.RightUp + $flag = 2 + } elseif ($Button -eq "middle") { + $down = $WindowsMessages.MiddleDown + $up = $WindowsMessages.MiddleUp + $flag = 16 + } + + $position = ConvertTo-OrcaLParam $point.X $point.Y + for ($i = 0; $i -lt [Math]::Max(1, $Count); $i++) { + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.MouseMove, [IntPtr]::Zero, $position) + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $down, [IntPtr]$flag, $position) + Start-Sleep -Milliseconds 35 + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $up, [IntPtr]::Zero, $position) + } +} + +function Send-OrcaDrag([IntPtr]$WindowHandle, $From, $To) { + $start = New-Object OrcaDesktopWin32+POINT + $start.X = [int]$From.x + $start.Y = [int]$From.y + [void][OrcaDesktopWin32]::ScreenToClient($WindowHandle, [ref]$start) + + $end = New-Object OrcaDesktopWin32+POINT + $end.X = [int]$To.x + $end.Y = [int]$To.y + [void][OrcaDesktopWin32]::ScreenToClient($WindowHandle, [ref]$end) + + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.MouseMove, [IntPtr]::Zero, (ConvertTo-OrcaLParam $start.X $start.Y)) + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.LeftDown, [IntPtr]1, (ConvertTo-OrcaLParam $start.X $start.Y)) + for ($step = 1; $step -le 12; $step++) { + $x = [int][Math]::Round($start.X + (($end.X - $start.X) * $step / 12)) + $y = [int][Math]::Round($start.Y + (($end.Y - $start.Y) * $step / 12)) + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.MouseMove, [IntPtr]1, (ConvertTo-OrcaLParam $x $y)) + Start-Sleep -Milliseconds 20 + } + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.LeftUp, [IntPtr]::Zero, (ConvertTo-OrcaLParam $end.X $end.Y)) +} + +function Send-OrcaText([IntPtr]$WindowHandle, [string]$Text) { + foreach ($character in $Text.ToCharArray()) { + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.Char, [IntPtr][int][char]$character, [IntPtr]::Zero) + Start-Sleep -Milliseconds 8 + } +} + +function Get-OrcaVirtualKey([string]$Key) { + $normalized = $Key.ToLowerInvariant() + $map = @{ + "return" = 0x0D; "enter" = 0x0D; "tab" = 0x09; "escape" = 0x1B; "esc" = 0x1B + "backspace" = 0x08; "delete" = 0x2E; "space" = 0x20; "left" = 0x25 + "up" = 0x26; "right" = 0x27; "down" = 0x28; "home" = 0x24; "end" = 0x23 + } + if ($map.ContainsKey($normalized)) { return $map[$normalized] } + if ($normalized.Length -eq 1) { return [int][char]$normalized.ToUpperInvariant()[0] } + throw "Unsupported key: $Key" +} + +function Send-OrcaKey([IntPtr]$WindowHandle, [string]$Key) { + $virtualKey = Get-OrcaVirtualKey $Key + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.KeyDown, [IntPtr]$virtualKey, [IntPtr]::Zero) + Start-Sleep -Milliseconds 25 + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.KeyUp, [IntPtr]$virtualKey, [IntPtr]::Zero) +} + +function Get-OrcaModifierVirtualKey([string]$Modifier) { + switch ($Modifier.ToLowerInvariant()) { + { $_ -in @("ctrl", "control", "cmdorctrl", "commandorcontrol") } { return 0x11 } + { $_ -in @("shift") } { return 0x10 } + { $_ -in @("alt", "option") } { return 0x12 } + { $_ -in @("meta", "super", "win", "cmd", "command") } { return 0x5B } + default { throw "Unsupported modifier: $Modifier" } + } +} + +function Send-OrcaHotkey([IntPtr]$WindowHandle, [string]$KeySpec) { + $parts = @($KeySpec.Split("+") | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($parts.Count -eq 0) { throw "Unsupported key: $KeySpec" } + $key = $parts[$parts.Count - 1] + $modifiers = @() + if ($parts.Count -gt 1) { + $modifiers = @($parts[0..($parts.Count - 2)] | ForEach-Object { Get-OrcaModifierVirtualKey $_ }) + } + foreach ($modifier in $modifiers) { + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.KeyDown, [IntPtr]$modifier, [IntPtr]::Zero) + } + Send-OrcaKey $WindowHandle $key + for ($i = $modifiers.Count - 1; $i -ge 0; $i--) { + [void][OrcaDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.KeyUp, [IntPtr]$modifiers[$i], [IntPtr]::Zero) + } +} + +function Send-OrcaPasteText([IntPtr]$WindowHandle, [string]$Text) { + $previous = $null + $hadPrevious = $false + try { $previous = [System.Windows.Forms.Clipboard]::GetDataObject() } catch {} + $hadPrevious = $null -ne $previous + try { + Set-Clipboard -Value $Text + Send-OrcaHotkey $WindowHandle "Ctrl+v" + } finally { + if ($hadPrevious) { + [System.Windows.Forms.Clipboard]::SetDataObject($previous, $true) + } else { + [System.Windows.Forms.Clipboard]::Clear() + } + } +} + +function Invoke-OrcaOperation($Operation) { + $includeScreenshot = -not [bool]$Operation.noScreenshot + if ($Operation.tool -eq "handshake") { + return [pscustomobject]@{ ok = $true; capabilities = Get-OrcaHandshake } + } + if ($Operation.tool -eq "list_apps") { + return [pscustomobject]@{ ok = $true; apps = @(Get-OrcaAppList) } + } + if ($Operation.tool -eq "list_windows") { + $list = Get-OrcaWindowList $Operation.app + return [pscustomobject]@{ ok = $true; app = $list.app; windows = @($list.windows) } + } + if ($Operation.tool -eq "get_app_state") { + return [pscustomobject]@{ ok = $true; snapshot = New-OrcaSnapshot $Operation.app $includeScreenshot $Operation.windowId $Operation.windowIndex ([bool]$Operation.restoreWindow) } + } + + $process = Find-OrcaProcess $Operation.app + if ([bool]$Operation.restoreWindow) { Restore-OrcaWindow $process } + Assert-OrcaWindowTarget $process $Operation.windowId $Operation.windowIndex + $root = Get-OrcaRootElement $process + $windowFrame = if ($null -ne $Operation.windowBounds) { $Operation.windowBounds } else { Get-OrcaWindowFrame $process $root } + $element = Find-OrcaElement $root $Operation.element + $fromElement = Find-OrcaElement $root $Operation.fromElement + $toElement = Find-OrcaElement $root $Operation.toElement + $handle = [IntPtr]$process.MainWindowHandle + if ($Operation.tool -in @("type_text", "press_key", "hotkey", "paste_text")) { + Assert-OrcaKeyboardFocus $handle $Operation + } + $action = $null + + switch ($Operation.tool) { + "click" { + $handledByPattern = $false + if ($null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and [int]$Operation.click_count -le 1) { + $handledByPattern = Invoke-OrcaPrimaryAction $element + } + if (-not $handledByPattern) { + $point = Get-OrcaElementScreenPoint $element + if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame } + Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button ([int]$Operation.click_count) + $action = [pscustomobject]@{ path = "synthetic"; actionName = $null; fallbackReason = "actionUnsupported" } + } else { + $action = [pscustomobject]@{ path = "accessibility"; actionName = "primaryAction"; fallbackReason = $null } + } + } + "perform_secondary_action" { + if ($null -eq $element) { throw "unknown element_index" } + if (-not (Invoke-OrcaNamedAction $element $Operation.action)) { + throw "$($Operation.action) is not a valid secondary action" + } + $action = [pscustomobject]@{ path = "accessibility"; actionName = $Operation.action; fallbackReason = $null } + } + "scroll" { + $delta = 120 * [int][Math]::Max(1, [Math]::Ceiling([double]$Operation.pages)) + if ($Operation.direction -eq "down" -or $Operation.direction -eq "right") { $delta = -1 * $delta } + $point = Get-OrcaElementScreenPoint $element + if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame } + [void][OrcaDesktopWin32]::PostMessage($handle, $WindowsMessages.Wheel, (ConvertTo-OrcaWheelParam $delta), (ConvertTo-OrcaLParam $point.x $point.y)) + $action = [pscustomobject]@{ path = "synthetic"; actionName = "scroll"; fallbackReason = $null } + } + "drag" { + $from = Get-OrcaElementScreenPoint $fromElement + if ($null -eq $from -and $null -ne $Operation.fromElement) { throw "stale element frame; run get-app-state again and use a fresh element index" } + if ($null -eq $from) { $from = @{ x = $windowFrame.x + [double]$Operation.from_x; y = $windowFrame.y + [double]$Operation.from_y } } + $to = Get-OrcaElementScreenPoint $toElement + if ($null -eq $to -and $null -ne $Operation.toElement) { throw "stale element frame; run get-app-state again and use a fresh element index" } + if ($null -eq $to) { $to = @{ x = $windowFrame.x + [double]$Operation.to_x; y = $windowFrame.y + [double]$Operation.to_y } } + Send-OrcaDrag $handle $from $to + $action = [pscustomobject]@{ path = "synthetic"; actionName = "drag"; fallbackReason = $null } + } + "type_text" { + Send-OrcaText $handle ([string]$Operation.text) + $action = [pscustomobject]@{ path = "synthetic"; actionName = "typeText"; fallbackReason = $null } + } + "press_key" { + Send-OrcaKey $handle ([string]$Operation.key) + $action = [pscustomobject]@{ path = "synthetic"; actionName = "pressKey"; fallbackReason = $null } + } + "hotkey" { + Send-OrcaHotkey $handle ([string]$Operation.key) + $action = [pscustomobject]@{ path = "synthetic"; actionName = "hotkey"; fallbackReason = $null; verification = [pscustomobject]@{ state = "unverified"; reason = "synthetic_input" } } + } + "paste_text" { + Send-OrcaPasteText $handle ([string]$Operation.text) + $action = [pscustomobject]@{ path = "clipboard"; actionName = "paste"; fallbackReason = $null; verification = [pscustomobject]@{ state = "unverified"; reason = "clipboard_paste" } } + } + "set_value" { + if ($null -eq $element -or -not (Set-OrcaElementValue $element ([string]$Operation.value))) { + throw "element value is not settable" + } + $action = [pscustomobject]@{ path = "accessibility"; actionName = "setValue"; fallbackReason = $null } + } + default { + throw "unsupported tool: $($Operation.tool)" + } + } + + try { + $snapshot = New-OrcaSnapshot $Operation.app $includeScreenshot $Operation.windowId $Operation.windowIndex + } catch { + if ($null -eq $Operation.windowId -and $null -eq $Operation.windowIndex) { throw } + if ($null -eq $action.verification) { + $action | Add-Member -NotePropertyName verification -NotePropertyValue ([pscustomobject]@{ state = "unverified"; reason = "window_changed" }) + } + $snapshot = New-OrcaSnapshot $Operation.app $includeScreenshot $null $null + } + [pscustomobject]@{ ok = $true; action = $action; snapshot = $snapshot } +} + +try { + $operation = Read-OrcaOperation $OperationPath + Write-OrcaJson (Invoke-OrcaOperation $operation) +} catch { + Write-OrcaJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message }) +} diff --git a/package.json b/package.json index 0dd8f905fb7..0eb6577a939 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.33-rc.1", + "version": "1.3.51-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -25,10 +25,13 @@ "start": "electron-vite preview", "dev": "node config/scripts/run-electron-vite-dev.mjs", "build:relay": "node config/scripts/build-relay.mjs", + "build:computer-macos": "node config/scripts/build-computer-macos.mjs", + "smoke:computer": "node config/scripts/computer-use-smoke.mjs", + "verify:computer-native": "node config/scripts/verify-computer-native.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", - "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", - "build:release": "pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", + "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:computer-macos && pnpm run build:electron-vite && pnpm run build:cli", + "build:release": "pnpm run build:relay && pnpm run build:computer-macos && pnpm run verify:computer-native && pnpm run build:electron-vite && pnpm run build:cli", "postinstall": "pnpm rebuild electron && node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir", @@ -38,7 +41,8 @@ "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --linux", "test:e2e": "npx playwright test --config tests/playwright.config.ts --project electron-headless", - "test:e2e:headful": "npx playwright test --config tests/playwright.config.ts --project electron-headful" + "test:e2e:headful": "npx playwright test --config tests/playwright.config.ts --project electron-headful", + "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -49,39 +53,39 @@ "@linear/sdk": "^82.1.0", "@monaco-editor/react": "^4.7.0", "@parcel/watcher": "^2.5.6", - "@tanstack/react-virtual": "^3.13.23", - "@tiptap/extension-code-block-lowlight": "^3.22.4", - "@tiptap/extension-image": "^3.22.4", - "@tiptap/extension-link": "^3.22.4", - "@tiptap/extension-placeholder": "^3.22.4", + "@tanstack/react-virtual": "^3.13.24", + "@tiptap/extension-code-block-lowlight": "^3.22.5", + "@tiptap/extension-image": "^3.22.5", + "@tiptap/extension-link": "^3.22.5", + "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/extension-table": "3.22.4", "@tiptap/extension-table-cell": "3.22.4", "@tiptap/extension-table-header": "3.22.4", "@tiptap/extension-table-row": "3.22.4", - "@tiptap/extension-task-item": "^3.22.4", - "@tiptap/extension-task-list": "^3.22.4", - "@tiptap/markdown": "^3.22.4", - "@tiptap/pm": "^3.22.4", - "@tiptap/react": "^3.22.4", - "@tiptap/starter-kit": "^3.22.4", - "@xterm/addon-fit": "0.12.0-beta.198", - "@xterm/addon-ligatures": "0.11.0-beta.198", - "@xterm/addon-search": "0.17.0-beta.198", - "@xterm/addon-serialize": "0.15.0-beta.198", - "@xterm/addon-unicode11": "0.10.0-beta.198", - "@xterm/addon-web-links": "0.13.0-beta.198", - "@xterm/addon-webgl": "0.20.0-beta.197", - "@xterm/headless": "6.1.0-beta.198", - "@xterm/xterm": "6.1.0-beta.198", + "@tiptap/extension-task-item": "^3.22.5", + "@tiptap/extension-task-list": "^3.22.5", + "@tiptap/markdown": "^3.22.5", + "@tiptap/pm": "^3.22.5", + "@tiptap/react": "^3.22.5", + "@tiptap/starter-kit": "^3.22.5", + "@xterm/addon-fit": "0.12.0-beta.216", + "@xterm/addon-ligatures": "0.11.0-beta.216", + "@xterm/addon-search": "0.17.0-beta.216", + "@xterm/addon-serialize": "0.15.0-beta.216", + "@xterm/addon-unicode11": "0.10.0-beta.216", + "@xterm/addon-web-links": "0.13.0-beta.216", + "@xterm/addon-webgl": "0.20.0-beta.215", + "@xterm/headless": "6.1.0-beta.216", + "@xterm/xterm": "6.1.0-beta.216", "agent-browser": "~0.24.1", "better-sqlite3": "^12.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dompurify": "^3.4.0", + "dompurify": "^3.4.2", "electron-updater": "^6.8.3", "github-slugger": "^2.0.0", - "hosted-git-info": "^9.0.2", + "hosted-git-info": "^9.0.3", "html-to-image": "^1.11.13", "katex": "^0.16.45", "lowlight": "^3.3.0", @@ -89,8 +93,9 @@ "mermaid": "^11.14.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", - "pdfjs-dist": "^5.6.205", - "posthog-node": "^5.33.0", + "pdfjs-dist": "^5.7.284", + "posthog-node": "^5.33.3", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", @@ -102,44 +107,46 @@ "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "shadcn": "^4.1.0", - "simple-git": "^3.33.0", + "shadcn": "^4.7.0", + "simple-git": "^3.36.0", "sonner": "^2.0.7", "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "tweetnacl": "^1.0.3", "ws": "^8.20.0", - "zod": "^4.3.6", - "zustand": "^5.0.12" + "zod": "~4.4.3", + "zustand": "^5.0.13" }, "devDependencies": { "@electron-toolkit/tsconfig": "^2.0.0", - "@electron/rebuild": "^4.0.3", + "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", - "@stablyai/playwright-test": "^2.1.13", - "@tailwindcss/vite": "^4.2.2", + "@stablyai/playwright-test": "^2.1.14", + "@tailwindcss/vite": "^4.2.4", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^25.5.0", + "@types/node": "^25.6.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260406.1", "@vitejs/plugin-react": "^5.2.0", - "electron": "^41.2.2", + "electron": "^41.5.0", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", "oxfmt": "^0.41.0", - "oxlint": "^1.56.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-grab": "^0.1.28", - "tailwindcss": "^4.2.2", + "oxlint": "^1.63.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-grab": "^0.1.33", + "tailwindcss": "^4.2.4", "typescript": "^5.9.3", - "vite": "^7.3.1", - "vitest": "^4.1.1" + "vite": "^7.3.2", + "vitest": "^4.1.5" }, "lint-staged": { "*.{ts,tsx,js,jsx,mts,cts}": [ @@ -165,7 +172,7 @@ ], "patchedDependencies": { "node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch", - "@xterm/addon-ligatures@0.11.0-beta.198": "config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch" + "@xterm/addon-ligatures@0.11.0-beta.216": "config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1eaf072407f..cd90f486cf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,11 +5,11 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - '@xterm/addon-ligatures@0.11.0-beta.198': + '@xterm/addon-ligatures@0.11.0-beta.216': hash: df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634 - path: config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch + path: config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch node-pty@1.1.0: - hash: d39825bae630ddf0fc73e83e6001057c70957161772ba078988767d8569cee63 + hash: 93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4 path: config/patches/node-pty@1.1.0.patch importers: @@ -18,100 +18,100 @@ importers: dependencies: '@dnd-kit/core': specifier: ^6.3.1 - version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@dnd-kit/sortable': specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) '@dnd-kit/utilities': specifier: ^3.2.2 - version: 3.2.2(react@19.2.4) + version: 3.2.2(react@19.2.5) '@electron-toolkit/preload': specifier: ^3.0.2 - version: 3.0.2(electron@41.3.0) + version: 3.0.2(electron@41.5.0) '@electron-toolkit/utils': specifier: ^4.0.0 - version: 4.0.0(electron@41.3.0) + version: 4.0.0(electron@41.5.0) '@linear/sdk': specifier: ^82.1.0 - version: 82.1.0(graphql@16.13.1) + version: 82.1.0(graphql@16.13.2) '@monaco-editor/react': specifier: ^4.7.0 - version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 '@tanstack/react-virtual': - specifier: ^3.13.23 - version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tiptap/extension-code-block-lowlight': - specifier: ^3.22.4 - version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(highlight.js@11.11.1)(lowlight@3.3.0) + specifier: ^3.22.5 + version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0) '@tiptap/extension-image': - specifier: ^3.22.4 - version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + specifier: ^3.22.5 + version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) '@tiptap/extension-link': - specifier: ^3.22.4 - version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + specifier: ^3.22.5 + version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) '@tiptap/extension-placeholder': - specifier: ^3.22.4 - version: 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + specifier: ^3.22.5 + version: 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) '@tiptap/extension-table': specifier: 3.22.4 - version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + version: 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) '@tiptap/extension-table-cell': specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) '@tiptap/extension-table-header': specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) '@tiptap/extension-table-row': specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) '@tiptap/extension-task-item': - specifier: ^3.22.4 - version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + specifier: ^3.22.5 + version: 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) '@tiptap/extension-task-list': - specifier: ^3.22.4 - version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + specifier: ^3.22.5 + version: 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) '@tiptap/markdown': - specifier: ^3.22.4 - version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + specifier: ^3.22.5 + version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) '@tiptap/pm': - specifier: ^3.22.4 - version: 3.22.4 + specifier: ^3.22.5 + version: 3.22.5 '@tiptap/react': - specifier: ^3.22.4 - version: 3.22.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^3.22.5 + version: 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tiptap/starter-kit': - specifier: ^3.22.4 - version: 3.22.4 + specifier: ^3.22.5 + version: 3.22.5 '@xterm/addon-fit': - specifier: 0.12.0-beta.198 - version: 0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198) + specifier: 0.12.0-beta.216 + version: 0.12.0-beta.216(@xterm/xterm@6.1.0-beta.216) '@xterm/addon-ligatures': - specifier: 0.11.0-beta.198 - version: 0.11.0-beta.198(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.198) + specifier: 0.11.0-beta.216 + version: 0.11.0-beta.216(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.216) '@xterm/addon-search': - specifier: 0.17.0-beta.198 - version: 0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198) + specifier: 0.17.0-beta.216 + version: 0.17.0-beta.216(@xterm/xterm@6.1.0-beta.216) '@xterm/addon-serialize': - specifier: 0.15.0-beta.198 - version: 0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198) + specifier: 0.15.0-beta.216 + version: 0.15.0-beta.216(@xterm/xterm@6.1.0-beta.216) '@xterm/addon-unicode11': - specifier: 0.10.0-beta.198 - version: 0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198) + specifier: 0.10.0-beta.216 + version: 0.10.0-beta.216(@xterm/xterm@6.1.0-beta.216) '@xterm/addon-web-links': - specifier: 0.13.0-beta.198 - version: 0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198) + specifier: 0.13.0-beta.216 + version: 0.13.0-beta.216(@xterm/xterm@6.1.0-beta.216) '@xterm/addon-webgl': - specifier: 0.20.0-beta.197 - version: 0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198) + specifier: 0.20.0-beta.215 + version: 0.20.0-beta.215(@xterm/xterm@6.1.0-beta.216) '@xterm/headless': - specifier: 6.1.0-beta.198 - version: 6.1.0-beta.198 + specifier: 6.1.0-beta.216 + version: 6.1.0-beta.216 '@xterm/xterm': - specifier: 6.1.0-beta.198 - version: 6.1.0-beta.198 + specifier: 6.1.0-beta.216 + version: 6.1.0-beta.216 agent-browser: specifier: ~0.24.1 version: 0.24.1 @@ -126,10 +126,10 @@ importers: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) dompurify: - specifier: ^3.4.0 - version: 3.4.0 + specifier: ^3.4.2 + version: 3.4.2 electron-updater: specifier: ^6.8.3 version: 6.8.3 @@ -137,8 +137,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 hosted-git-info: - specifier: ^9.0.2 - version: 9.0.2 + specifier: ^9.0.3 + version: 9.0.3 html-to-image: specifier: ^1.11.13 version: 1.11.13 @@ -150,7 +150,7 @@ importers: version: 3.3.0 lucide-react: specifier: ^0.577.0 - version: 0.577.0(react@19.2.4) + version: 0.577.0(react@19.2.5) mermaid: specifier: ^11.14.0 version: 11.14.0 @@ -159,19 +159,22 @@ importers: version: 0.55.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=d39825bae630ddf0fc73e83e6001057c70957161772ba078988767d8569cee63) + version: 1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4) pdfjs-dist: - specifier: ^5.6.205 - version: 5.6.205 + specifier: ^5.7.284 + version: 5.7.284 posthog-node: - specifier: ^5.33.0 - version: 5.33.0 + specifier: ^5.33.3 + version: 5.33.3 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 radix-ui: specifier: ^1.4.3 - version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + version: 10.1.0(@types/react@19.2.14)(react@19.2.5) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -200,14 +203,14 @@ importers: specifier: ^6.0.0 version: 6.0.0 shadcn: - specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3) + specifier: ^4.7.0 + version: 4.7.0(@types/node@25.6.0)(typescript@5.9.3) simple-git: - specifier: ^3.33.0 - version: 3.33.0 + specifier: ^3.36.0 + version: 3.36.0 sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ssh2: specifier: ^1.17.0 version: 1.17.0 @@ -217,37 +220,43 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 ws: specifier: ^8.20.0 version: 8.20.0 zod: - specifier: ^4.3.6 - version: 4.3.6 + specifier: ~4.4.3 + version: 4.4.3 zustand: - specifier: ^5.0.12 - version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + specifier: ^5.0.13 + version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) devDependencies: '@electron-toolkit/tsconfig': specifier: ^2.0.0 - version: 2.0.0(@types/node@25.5.0) + version: 2.0.0(@types/node@25.6.0) '@electron/rebuild': - specifier: ^4.0.3 - version: 4.0.3 + specifier: ^4.0.4 + version: 4.0.4 '@playwright/test': specifier: ^1.59.1 version: 1.59.1 '@stablyai/playwright-test': - specifier: ^2.1.13 - version: 2.1.14(@playwright/test@1.59.1)(zod@4.3.6) + specifier: ^2.1.14 + version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + specifier: ^4.2.4 + version: 4.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 '@types/node': - specifier: ^25.5.0 - version: 25.5.0 + specifier: ^25.6.0 + version: 25.6.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -265,16 +274,16 @@ importers: version: 7.0.0-dev.20260406.1 '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) electron: - specifier: ^41.2.2 - version: 41.3.0 + specifier: ^41.5.0 + version: 41.5.0 electron-builder: specifier: ^26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 5.0.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -285,29 +294,29 @@ importers: specifier: ^0.41.0 version: 0.41.0 oxlint: - specifier: ^1.56.0 - version: 1.56.0 + specifier: ^1.63.0 + version: 1.63.0 react: - specifier: ^19.2.4 - version: 19.2.4 + specifier: ^19.2.5 + version: 19.2.5 react-dom: - specifier: ^19.2.4 - version: 19.2.4(react@19.2.4) + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) react-grab: - specifier: ^0.1.28 - version: 0.1.28(@types/react@19.2.14)(react@19.2.4) + specifier: ^0.1.33 + version: 0.1.33(react@19.2.5) tailwindcss: - specifier: ^4.2.2 - version: 4.2.2 + specifier: ^4.2.4 + version: 4.2.4 typescript: specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + specifier: ^7.3.2 + version: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) vitest: - specifier: ^4.1.1 - version: 4.1.1(@types/node@25.5.0)(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) packages: @@ -317,16 +326,17 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@antfu/ni@0.23.2': - resolution: {integrity: sha512-FSEVWXvwroExDXUu8qV6Wqp2X3D1nJ0Li4LFymCyvCVrm7I3lNfG0zZWSWvGU1RE7891eTnFTyh31L3igOwNKQ==} + '@antfu/ni@30.1.0': + resolution: {integrity: sha512-3VuAbPjgY52rQNn4wABaXMhBU2Oq91uy6L8nX49eJ35OLI68CyckGU+HZxcaHix4ymuGM2nFL1D6sLpgODK5xw==} + engines: {node: '>=20.19.0'} hasBin: true '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} '@babel/core@7.29.0': @@ -345,8 +355,8 @@ packages: resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -403,8 +413,8 @@ packages: resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true @@ -512,13 +522,13 @@ packages: peerDependencies: react: '>=16.8.0' - '@dotenvx/dotenvx@1.57.0': - resolution: {integrity: sha512-WsTEcqfHzKmLFZh3jLGd7o4iCkrIupp+qFH2FJUJtQXUh2GcOnLXD00DcrhlO4H8QSmaKnW9lugOEbrdpu25kA==} + '@dotenvx/dotenvx@1.65.0': + resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==} hasBin: true - '@ecies/ciphers@0.2.5': - resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} - engines: {bun: '>=1', deno: '>=2', node: '>=16'} + '@ecies/ciphers@0.2.6': + resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} peerDependencies: '@noble/ciphers': ^1.0.0 @@ -563,8 +573,8 @@ packages: engines: {node: '>=12.0.0'} hasBin: true - '@electron/rebuild@4.0.3': - resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==} + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} engines: {node: '>=22.12.0'} hasBin: true @@ -583,8 +593,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -595,8 +605,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -607,8 +617,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -619,8 +629,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -631,8 +641,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -643,8 +653,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -655,8 +665,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -667,8 +677,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -679,8 +689,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -691,8 +701,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -703,8 +713,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -715,8 +725,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -727,8 +737,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -739,8 +749,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -751,8 +761,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -763,8 +773,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -775,8 +785,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -787,8 +797,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -799,8 +809,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -811,8 +821,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -823,8 +833,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -835,8 +845,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -847,8 +857,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -859,8 +869,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -871,8 +881,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -883,8 +893,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -909,8 +919,8 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -918,48 +928,44 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@3.1.0': - resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@iconify/utils@3.1.1': + resolution: {integrity: sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==} - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} + '@inquirer/confirm@6.0.12': + resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} + '@inquirer/core@11.1.9': + resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -998,14 +1004,11 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@medv/finder@4.0.2': - resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==} - '@mermaid-js/parser@1.1.0': resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1024,83 +1027,83 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@mswjs/interceptors@0.41.3': - resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + '@mswjs/interceptors@0.41.8': + resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} engines: {node: '>=18'} - '@napi-rs/canvas-android-arm64@0.1.99': - resolution: {integrity: sha512-9OCRt8VVxA17m32NWZKyNC2qamdaS/SC5CEOIQwFngRq0DIeVm4PDal+6Ljnhqm2whZiC63DNuKZ4xSp2nbj9w==} + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@0.1.99': - resolution: {integrity: sha512-lupMDMy1+H38dhyCcLirOKKVUyzzlxi7j7rGPLI3vViMHOoPjcXO1b10ivy+ad+q6MiwHfoLjKTCoLke5ySOBg==} + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.99': - resolution: {integrity: sha512-fdz02t4w8n6Ii/rYhWig6STb/zcTmCC/6YZTGmjoDeidDwn9Wf0ukQVynhCPEs29vqUc66wHZKsuIgMs9tycCg==} + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.99': - resolution: {integrity: sha512-w4FwVwlNo00ezeRhfY62IVIyt6G3u8wodkPtiqWc52BUHx+VDBUM2vkS3ogfANaLI7hnf3s6WK4LyZVUjBg1lA==} + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.99': - resolution: {integrity: sha512-8JvHeexKQ8c7g0q7YJ29NVQwnf1ePghP9ys9ZN0R0qzyqJQ9Uw6N9qnDINArlm3IYHexB7LjzArIfhQiqSDGvQ==} + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-arm64-musl@0.1.99': - resolution: {integrity: sha512-Z+6nyLdJXWzLPVxi4H6g9TJop4DwN3KSgHWto5JCbZV5/uKoVqcSynPs0tGlUHOoWI8S8tEvJspz51GQkvr07w==} + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.99': - resolution: {integrity: sha512-jAnfOUv4IO1l8Levk5t85oVtEBOXLa07KnIUgWo1CDlPxiqpxS3uBfiE38Lvj/CQgHaNF6Nxk/SaemwLgsVJgw==} + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-gnu@0.1.99': - resolution: {integrity: sha512-mIkXw3fGmbYyFjSmfWEvty4jN+rwEOmv0+Dy9bRvvTzLYWCgm3RMgUEQVfAKFw96nIRFnyNZiK83KNQaVVFjng==} + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-musl@0.1.99': - resolution: {integrity: sha512-f3Uz2P0RgrtBHISxZqr6yiYXJlTDyCVBumDacxo+4AmSg7z0HiqYZKGWC/gszq3fbPhyQUya1W2AEteKxT9Y6A==} + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/canvas-win32-arm64-msvc@0.1.99': - resolution: {integrity: sha512-XE6KUkfqRsCNejcoRMiMr3RaUeObxNf6y7dut3hrq2rn7PzfRTZgrjF1F/B2C7FcdgqY/vSHWpQeMuNz1vTNHg==} + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.99': - resolution: {integrity: sha512-plMYGVbc/vmmPF9MtmHbwNk1rL1Aj53vQZt+Gnv1oZn6gmd9jEHHJ0n9Nd2nxa5sKH7TS5IjkCDM6289O0d6PQ==} + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@0.1.99': - resolution: {integrity: sha512-zN4eQlK3eBf7aJBcTHZilpBH3tDekBzPMIWC8r0s94Ecl73XfOyFi4w7yKFMRVUT0lvNQjtOL8YSrwqQj6mZFg==} + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} engines: {node: '>= 10'} '@noble/ciphers@1.3.0': @@ -1127,17 +1130,12 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@npmcli/agent@3.0.0': - resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/fs@4.0.0': - resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} - engines: {node: ^18.17.0 || >=20.5.0} - '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + '@open-draft/logger@0.3.0': resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} @@ -1266,124 +1264,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.56.0': - resolution: {integrity: sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==} + '@oxlint/binding-android-arm-eabi@1.63.0': + resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.56.0': - resolution: {integrity: sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==} + '@oxlint/binding-android-arm64@1.63.0': + resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.56.0': - resolution: {integrity: sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==} + '@oxlint/binding-darwin-arm64@1.63.0': + resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.56.0': - resolution: {integrity: sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==} + '@oxlint/binding-darwin-x64@1.63.0': + resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.56.0': - resolution: {integrity: sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==} + '@oxlint/binding-freebsd-x64@1.63.0': + resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.56.0': - resolution: {integrity: sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==} + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.56.0': - resolution: {integrity: sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==} + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.56.0': - resolution: {integrity: sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==} + '@oxlint/binding-linux-arm64-gnu@1.63.0': + resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.56.0': - resolution: {integrity: sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==} + '@oxlint/binding-linux-arm64-musl@1.63.0': + resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.56.0': - resolution: {integrity: sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==} + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.56.0': - resolution: {integrity: sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==} + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.56.0': - resolution: {integrity: sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==} + '@oxlint/binding-linux-riscv64-musl@1.63.0': + resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.56.0': - resolution: {integrity: sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==} + '@oxlint/binding-linux-s390x-gnu@1.63.0': + resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.56.0': - resolution: {integrity: sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==} + '@oxlint/binding-linux-x64-gnu@1.63.0': + resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.56.0': - resolution: {integrity: sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==} + '@oxlint/binding-linux-x64-musl@1.63.0': + resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.56.0': - resolution: {integrity: sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==} + '@oxlint/binding-openharmony-arm64@1.63.0': + resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.56.0': - resolution: {integrity: sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==} + '@oxlint/binding-win32-arm64-msvc@1.63.0': + resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.56.0': - resolution: {integrity: sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==} + '@oxlint/binding-win32-ia32-msvc@1.63.0': + resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.56.0': - resolution: {integrity: sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==} + '@oxlint/binding-win32-x64-msvc@1.63.0': + resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1476,20 +1474,16 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} hasBin: true - '@posthog/core@1.28.0': - resolution: {integrity: sha512-753giUMWuk602UtS101tDZuNcwiKkr+3UEhLgfOwHAk2W32n53knOxAjyWT0JwMq5/+0uSQ2y4uaZXQAxwvBSw==} + '@posthog/core@1.28.3': + resolution: {integrity: sha512-SOy0aphKawZzp8jxfeOpTcXPwi6ii0I2V6tX8YXnM+WbxKKR/R+BXLK0jS6LV8kZtW3H5YxmPAfuIbUP1UnGTw==} - '@posthog/types@1.372.6': - resolution: {integrity: sha512-sqI36LBvuo8xcYsXIlVa0q3IXJJjqtatM2LrXlyOM7kgHrldBwS4ldzaTXrTdpe/TiIl1b4ZHxtSHMzPig+DnQ==} + '@posthog/types@1.372.9': + resolution: {integrity: sha512-B7k9S+H9WUKHXxe1HOkQWbpWtMcrBvsodm5stZaLQ3pYxf9TowtwssdzTtX4hHjzSYqgrS1IpNnJX4vs1KgBzA==} '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1893,6 +1887,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.7': resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} peerDependencies: @@ -1993,6 +2000,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.2.6': resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} peerDependencies: @@ -2181,154 +2197,160 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@react-grab/cli@0.1.28': - resolution: {integrity: sha512-IE4bTeH0mCq0FBRaYRUtdiIfvO7NCv14lHS4TiTY8YNG5tPYwHnLZZtniud0ElmLIWGP95Kk6E7A6J2uDmOY+Q==} + '@react-grab/cli@0.1.33': + resolution: {integrity: sha512-UOc3PwN11Osw0NzaxRLK8trP4X+5iW1Dst3gvHRCafe3wXHyadzHYH8H1hdkcXdlIx3gsoD9ASJ+G/JH+A/jqA==} hasBin: true '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.59.1': - resolution: {integrity: sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==} + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.59.1': - resolution: {integrity: sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==} + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.59.1': - resolution: {integrity: sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==} + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.59.1': - resolution: {integrity: sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==} + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.59.1': - resolution: {integrity: sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==} + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.59.1': - resolution: {integrity: sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==} + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.59.1': - resolution: {integrity: sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.59.1': - resolution: {integrity: sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==} + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.59.1': - resolution: {integrity: sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==} + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.59.1': - resolution: {integrity: sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==} + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.59.1': - resolution: {integrity: sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==} + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.59.1': - resolution: {integrity: sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==} + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.59.1': - resolution: {integrity: sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.59.1': - resolution: {integrity: sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==} + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.59.1': - resolution: {integrity: sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==} + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.59.1': - resolution: {integrity: sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==} + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.59.1': - resolution: {integrity: sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==} + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.59.1': - resolution: {integrity: sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==} + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.59.1': - resolution: {integrity: sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==} + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.59.1': - resolution: {integrity: sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==} + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.59.1': - resolution: {integrity: sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==} + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.59.1': - resolution: {integrity: sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==} + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.59.1': - resolution: {integrity: sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==} + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.59.1': - resolution: {integrity: sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==} + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.59.1': - resolution: {integrity: sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==} + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} cpu: [x64] os: [win32] '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@simple-git/args-pathspec@1.0.3': + resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} + + '@simple-git/argv-parser@1.1.1': + resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -2364,69 +2386,69 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -2437,171 +2459,171 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/react-virtual@3.13.23': - resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.13.23': - resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} - '@tiptap/core@3.22.4': - resolution: {integrity: sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==} + '@tiptap/core@3.22.5': + resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==} peerDependencies: - '@tiptap/pm': 3.22.4 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-blockquote@3.22.4': - resolution: {integrity: sha512-7/61kNPbGFhMgM//zMknD0pSb69rGdRIkpulXOWS1JBrFHkH6hjZDfrOETNzgKkO+NlmzVl9rXSTv0xauS3lzA==} + '@tiptap/extension-blockquote@3.22.5': + resolution: {integrity: sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-bold@3.22.4': - resolution: {integrity: sha512-jIaPKfNOQu2lhpbLDvtwlQqM+mjF+Kk+auHpzYjBnsuwUli1Cl5ZOau7RH+rru/SQvZe1DtpQlANujDywugZAA==} + '@tiptap/extension-bold@3.22.5': + resolution: {integrity: sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-bubble-menu@3.22.4': - resolution: {integrity: sha512-v4pux5Ql3THAEjaLMY4ldtdy/Xy2qU7PJLBkq8ugLp8qicaKC+tpqxp6sGif4vLIjz7Ap5hurRbTNbXzszyyHA==} + '@tiptap/extension-bubble-menu@3.22.5': + resolution: {integrity: sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-bullet-list@3.22.4': - resolution: {integrity: sha512-TB+d3fGcTixYjO7coKqTr1mGTJuqr8hjDCPUFgzuvKyJnBhqWITmBzQ/8CLq4rr6mihgGURbD3N+xkQuPAKFiw==} + '@tiptap/extension-bullet-list@3.22.5': + resolution: {integrity: sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==} peerDependencies: - '@tiptap/extension-list': 3.22.4 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-code-block-lowlight@3.22.4': - resolution: {integrity: sha512-Gw01buMOF3EMaQHFADXMw53vvDaUcHC8ERTjw4IlwM33CGGDRQmn5cwxUvvhImyrc2FzzE2VmB5KC11PrtfORA==} + '@tiptap/extension-code-block-lowlight@3.22.5': + resolution: {integrity: sha512-lT0SxhjkDL1tKSeVDduV+SJ6kHdNFcbYBaUAwTufRtDt8FIYcSX6tWj5cPEXOFrC0PlJu7ybCnTEbXBdFP8Bnw==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/extension-code-block': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/extension-code-block': 3.22.5 + '@tiptap/pm': 3.22.5 highlight.js: ^11 lowlight: ^2 || ^3 - '@tiptap/extension-code-block@3.22.4': - resolution: {integrity: sha512-MEurzNXfMET3rhjpoPJYUgMfxTdTqbzT9+ToFrqNGAHocdXVm6m1hhO2frVC7fEtHPnxXKsn0Z3NUbCRkRTLuA==} + '@tiptap/extension-code-block@3.22.5': + resolution: {integrity: sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-code@3.22.4': - resolution: {integrity: sha512-cnbxmVhAcc7X3G81QUYEmKP0ve2hRmvAiFXBuuv9RUtQlBiRnzmhHoJOMgkX0CsMR7+8kMRpTfeDUYq2xp5s5w==} + '@tiptap/extension-code@3.22.5': + resolution: {integrity: sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-document@3.22.4': - resolution: {integrity: sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw==} + '@tiptap/extension-document@3.22.5': + resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-dropcursor@3.22.4': - resolution: {integrity: sha512-N9/yMDC35jJp0V/naL0+6gi4gUDUIcPpWEzFdCDWUSYBA8mt41c1kI1ZU7UTKYIBzTClenhYHRc2XKZxxx0+LQ==} + '@tiptap/extension-dropcursor@3.22.5': + resolution: {integrity: sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==} peerDependencies: - '@tiptap/extensions': 3.22.4 + '@tiptap/extensions': 3.22.5 - '@tiptap/extension-floating-menu@3.22.4': - resolution: {integrity: sha512-DFuyYxgaZPgxum5z1yvJPbfYCvDdO8geXsdyqt0qYYdiat3aGE4ncJhiLRIFDhSHBhaZg5eCgu/YPYAN6jZnrA==} + '@tiptap/extension-floating-menu@3.22.5': + resolution: {integrity: sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-gapcursor@3.22.4': - resolution: {integrity: sha512-UYBEUj3SFpKINIE7AdzcyeS3xICK+ee+YLBbuqNXyHStYChjJOohzJehqiqhjR16A88KQQ+ZjgyDcItKGygSog==} + '@tiptap/extension-gapcursor@3.22.5': + resolution: {integrity: sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==} peerDependencies: - '@tiptap/extensions': 3.22.4 + '@tiptap/extensions': 3.22.5 - '@tiptap/extension-hard-break@3.22.4': - resolution: {integrity: sha512-xq+a4dE7T6VwApCkh/yU3p30gn3F8g8Arb9CyEZm58/WIJUIGvHSTjDdHmvU16+kiWSBg+wOOsaFHhYjJjxcKA==} + '@tiptap/extension-hard-break@3.22.5': + resolution: {integrity: sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-heading@3.22.4': - resolution: {integrity: sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q==} + '@tiptap/extension-heading@3.22.5': + resolution: {integrity: sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-horizontal-rule@3.22.4': - resolution: {integrity: sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw==} + '@tiptap/extension-horizontal-rule@3.22.5': + resolution: {integrity: sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-image@3.22.4': - resolution: {integrity: sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA==} + '@tiptap/extension-image@3.22.5': + resolution: {integrity: sha512-ezMzA6w6UsPesQp6fxTQojI/IkGJLmkwR/VGTimva7sudP3HdSW8k3SGBkjfvp0L2xqUrC/l4nWOchu01A/xtQ==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-italic@3.22.4': - resolution: {integrity: sha512-fVSDx5AYXgDI3v2zZIqb7V8EewthwM2NJ/ZCX+XaxRsqNEpnjVhgHs7UlvDqK1wj2OJ6zmUNjPtVlAFRxwT+HQ==} + '@tiptap/extension-italic@3.22.5': + resolution: {integrity: sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-link@3.22.4': - resolution: {integrity: sha512-uoP3yus02uwGPVzW2QaEPJWVIrUb/r5nKm6c8DiJv9fNSX1+gykZZMg42c6GwRFLZ/vyfWjVCbAE03VMUqafgA==} + '@tiptap/extension-link@3.22.5': + resolution: {integrity: sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-list-item@3.22.4': - resolution: {integrity: sha512-H659KXTvggSypIDWSOJBZ37jh9pKjQriDDvYPYvOZCdfij0D0hsDXN/wXoypArneUkoBdgruHfTtMkFOaQlgkw==} + '@tiptap/extension-list-item@3.22.5': + resolution: {integrity: sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==} peerDependencies: - '@tiptap/extension-list': 3.22.4 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-list-keymap@3.22.4': - resolution: {integrity: sha512-t/zhker4oIS78AIGYDdFFfZC6zSBlszfD7z/zqFLGCg5PHNNgkZK5hKj6Vyix6D2SapRn/ajnx+8mhbKIUH5eA==} + '@tiptap/extension-list-keymap@3.22.5': + resolution: {integrity: sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==} peerDependencies: - '@tiptap/extension-list': 3.22.4 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-list@3.22.4': - resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==} + '@tiptap/extension-list@3.22.5': + resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-ordered-list@3.22.4': - resolution: {integrity: sha512-w77hPVf7pcHt97vfrybg/l0t5CimCd4y75OJKuHuo3CfgM5xbUP/gaPNMDyLLe7MYole/UHi/XvG3XjgzqTzAw==} + '@tiptap/extension-ordered-list@3.22.5': + resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==} peerDependencies: - '@tiptap/extension-list': 3.22.4 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-paragraph@3.22.4': - resolution: {integrity: sha512-de6dFkIhigiENESY6rNJ3yTVS/337ybfP30dNPudTwGe9oAu9ZCS+04j6QCvXSjhlI3ULiv7wiSHqrP26Gd+Hw==} + '@tiptap/extension-paragraph@3.22.5': + resolution: {integrity: sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-placeholder@3.22.4': - resolution: {integrity: sha512-Z3wtWL+KufwkC7CkJge5enAxx4q8C3oOYixme02snY9zfjX3V/1pjAmEfP4wxScgM5GIuTEJ83B9Yz3wRzPA6Q==} + '@tiptap/extension-placeholder@3.22.5': + resolution: {integrity: sha512-MZAohQ3FCS763BkhGXgaWRya6WruZjwRwEAkXP8vkxbERzl2OJRjniS4uXCWzAlRb3ttE103SnY7LMdM8FvsXw==} peerDependencies: - '@tiptap/extensions': 3.22.4 + '@tiptap/extensions': 3.22.5 - '@tiptap/extension-strike@3.22.4': - resolution: {integrity: sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg==} + '@tiptap/extension-strike@3.22.5': + resolution: {integrity: sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 '@tiptap/extension-table-cell@3.22.4': resolution: {integrity: sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==} @@ -2624,53 +2646,53 @@ packages: '@tiptap/core': 3.22.4 '@tiptap/pm': 3.22.4 - '@tiptap/extension-task-item@3.22.4': - resolution: {integrity: sha512-PhoiOMatdRXJU1HJz0fMP5N7wv0eYAz/Id/gphby/gdxjYQaMhJ7vQiLTR28EkVBkdntTUb1bwZ4XQn9thFtpw==} + '@tiptap/extension-task-item@3.22.5': + resolution: {integrity: sha512-OVJKiq67lU+RiC6slIhhgTJBlP/Vads6MZ7Ld5wxzCtWMdGKDuzQ1dgF7vrMEs7mhSeSH3phNcIdQ5ypYftZ9w==} peerDependencies: - '@tiptap/extension-list': 3.22.4 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-task-list@3.22.4': - resolution: {integrity: sha512-5M3XiZMZJ2mwWSUKPG4mb90g86rpgYw7yf8lBEkaCgke9XxsLg8mXmYRpCc6n/v1TQXryB+WDKuenCzJTx/4/A==} + '@tiptap/extension-task-list@3.22.5': + resolution: {integrity: sha512-SfZeJSALtFODs0i3fml1TSi4vQ4Uopu0p/LndK+mX5FGNBtNmWiy7Wr5cH03ANfzj8c2EzfGIyH+F2/V0HLK9g==} peerDependencies: - '@tiptap/extension-list': 3.22.4 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-text@3.22.4': - resolution: {integrity: sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg==} + '@tiptap/extension-text@3.22.5': + resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extension-underline@3.22.4': - resolution: {integrity: sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA==} + '@tiptap/extension-underline@3.22.5': + resolution: {integrity: sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==} peerDependencies: - '@tiptap/core': 3.22.4 + '@tiptap/core': 3.22.5 - '@tiptap/extensions@3.22.4': - resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==} + '@tiptap/extensions@3.22.5': + resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/markdown@3.22.4': - resolution: {integrity: sha512-gcoLOq5TBntw13QdeWMy7yc2X+b9yTplcFb5kpMQNgNoxlu0+jlX4LiBR+E6lOV+m+AtkprHHltTYyKG23bdOw==} + '@tiptap/markdown@3.22.5': + resolution: {integrity: sha512-lLuAySaY5EYNYLe7e4507B9yQMAEDJdOKy0g85UNFV8giorYLQx56aV2O94Qb9gv3egs5inkwVRNfeJzOWAwig==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/pm@3.22.4': - resolution: {integrity: sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==} + '@tiptap/pm@3.22.5': + resolution: {integrity: sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==} - '@tiptap/react@3.22.4': - resolution: {integrity: sha512-XIQZPwLakR1t8+Q1UeCpr+kUHDWxpJzGy9r2xUi3mpPd6Wh8dtNltScBkUlCcr0sqc6J1GF6Is02JJVQGmCZMA==} + '@tiptap/react@3.22.5': + resolution: {integrity: sha512-36WHEs+vPmB//V1ff7Ujcnpz7Ey5g8lhpI/0+hoanSbdiPMTQ7qZVWwMovIkMKDlqWVp2fxBgeYM1861jyFzTw==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.22.4': - resolution: {integrity: sha512-qWjw+vfdin1rzMRpRU4cC5tLTwMJtUpXeQukv+6mOqqvhptuwuZBjUHImVEJaSPoHXS7+1ut+nTnrLyWyEuE5Q==} + '@tiptap/starter-kit@3.22.5': + resolution: {integrity: sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==} '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -2831,22 +2853,20 @@ packages: '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react-reconciler@0.28.9': - resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} - peerDependencies: - '@types/react': '*' - '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2856,6 +2876,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} @@ -2925,8 +2948,8 @@ packages: resolution: {integrity: sha512-hlVajr01Y/ZmI9iZ7A6BgPxqXccGqxuc/PmVNdanr/LZdtsH9q11y2H3NFMaOrbPlDoeWxHvGUT3wsZllCphyg==} hasBin: true - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -2937,11 +2960,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vitest/expect@4.1.1': - resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} - '@vitest/mocker@4.1.1': - resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2951,71 +2974,74 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.1': - resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} - '@vitest/runner@4.1.1': - resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} - '@vitest/snapshot@4.1.1': - resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} - '@vitest/spy@4.1.1': - resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} - '@vitest/utils@4.1.1': - resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - deprecated: this version has critical issues, please update to the latest version - '@xterm/addon-fit@0.12.0-beta.198': - resolution: {integrity: sha512-1EZFzOaUjRnlo5p2diIV9+HHVtHqr0O7TEFCstSuFqV/5QaKkRiWiKaMfsPEg2pF5P7yBOASk73yUPII7oquaA==} + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + '@xterm/addon-fit@0.12.0-beta.216': + resolution: {integrity: sha512-IgKE3ngNodSnmj1O+EEYpKQZkSbAUbghPlCWd8G32RL0piIMqb3FX3BuYLnWZeLNoD9iMtublLMG1T9XjGeVvA==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.198 + '@xterm/xterm': ^6.1.0-beta.216 - '@xterm/addon-ligatures@0.11.0-beta.198': - resolution: {integrity: sha512-LpF9KYiixxybSdyWpBtaPg5S1NNosQU0vcYg9P71xZFVRPT5vjaFvxYd80hV0oeKdnT25EMgNxwxbqHasmHQvg==} + '@xterm/addon-ligatures@0.11.0-beta.216': + resolution: {integrity: sha512-NJrK8MRi64Kb1Kxz5Jmrnd7H2gTlBqM3wHfObneD7T4xGFoPZkeX2/Kit4IcHt2Y5CTAQFAc92DilsncVm1HnQ==} engines: {node: '>8.0.0'} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.198 + '@xterm/xterm': ^6.1.0-beta.216 - '@xterm/addon-search@0.17.0-beta.198': - resolution: {integrity: sha512-loNSX0cB4P+9Sg13sgB/cQPPBgdOi1hxRyZ75Jj90+4iKe227mRmgzdqU3wxr10nyFWJWCpysCjVT+47tdC8mQ==} + '@xterm/addon-search@0.17.0-beta.216': + resolution: {integrity: sha512-p//4yv5aB2eyQcCFmPQ/s7tTFSTwKbtxs89xP1gvMbT9QuZgjW9FNqaQKb1GLDWtMpSfZdy7RrUp4GiCg3PVJQ==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.198 + '@xterm/xterm': ^6.1.0-beta.216 - '@xterm/addon-serialize@0.15.0-beta.198': - resolution: {integrity: sha512-0yf13cLPaKX8nkgUqclcdbU5FexacA/xnvWzHzGFYTku0WH+SqygbQcDOcg4bTUGgCcvfZqntEb1NQpr+69KgQ==} + '@xterm/addon-serialize@0.15.0-beta.216': + resolution: {integrity: sha512-scj/83XfYHg5LiDscYDP9dnadUMZt8To1tbdncQP/dZq2pD4TsTMokW1pciVYYazLBxCeKZybbVLEuOo7IPCqQ==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.198 + '@xterm/xterm': ^6.1.0-beta.216 - '@xterm/addon-unicode11@0.10.0-beta.198': - resolution: {integrity: sha512-3itjZ5lTQ5tnDcpUAplX88BsTJTZOvEzX9wNTNMO2aPuctLavMNg2+pXU8KTkeRhUZnDyhx9Lsv81dDs94vWDQ==} + '@xterm/addon-unicode11@0.10.0-beta.216': + resolution: {integrity: sha512-i7TrEHOTzUEOClH1+6IHoHy7bR/XHVRBjHc5e0u6A1HucFkAlCU+bqUY8EfwNOh1/iUjuB06EtNh6BM1o/ZAlA==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.198 + '@xterm/xterm': ^6.1.0-beta.216 - '@xterm/addon-web-links@0.13.0-beta.198': - resolution: {integrity: sha512-qA/B1tuNLpqlzscrAVFjLKjuH0CmKKyfbTn78Y3RLKs6H7xxdYSE6/DjJ1zg9C6aDtLwF9lsaOFpPSPf2AfpYQ==} + '@xterm/addon-web-links@0.13.0-beta.216': + resolution: {integrity: sha512-ZP3BDy1na/37TZHO8FB+XHJFoO8muyPoOzkaL2X28n5A9ZFQPbf834EMRsvDczKnfQvtZcve+ZCmMJj1ZHGjow==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.198 + '@xterm/xterm': ^6.1.0-beta.216 - '@xterm/addon-webgl@0.20.0-beta.197': - resolution: {integrity: sha512-0o30FvLzfd9ODuB1gbpwYMGQHYiz0FebmVaMP0Idesw1sLIG3qc7aIIBQEUnWiBDsKbDHhaysW8WTxacg2AhaQ==} + '@xterm/addon-webgl@0.20.0-beta.215': + resolution: {integrity: sha512-oCbH3YxiGOzRcKxwTfSRCA1TqpoT/AitO2X5MuqD14DVnb4Z3rTKQYfHBA7R6HA7U4K9OzmtIsi5+VyEIEaWsg==} peerDependencies: - '@xterm/xterm': ^6.1.0-beta.198 + '@xterm/xterm': ^6.1.0-beta.216 - '@xterm/headless@6.1.0-beta.198': - resolution: {integrity: sha512-pDS/I5HLGwwJf8sWZFaT+qbHxgFu0qdVQv5/XDzhNyKXMXQJ7cL3ZsJsIox5JxLo2ZuYLVVY8/lirHronhOSfA==} + '@xterm/headless@6.1.0-beta.216': + resolution: {integrity: sha512-HCut3ABwBv5zVUAB+5KVTW4svr/fCeVMW9xtaJIhmXZsNHOrhVFOGX7E2CYzRa8xdX/aI2JdTIhgLl8xjcHEoA==} - '@xterm/xterm@6.1.0-beta.198': - resolution: {integrity: sha512-udjhNFi4YfypbVBJV7gFNwQV3KziZFJdq+fg87WefGF5JkGbC+4LvIhEsHXR0Kb5Ez8ESxeqM7Yjvj+kw8zDAg==} + '@xterm/xterm@6.1.0-beta.216': + resolution: {integrity: sha512-87rfymzVje5eYUlGG94hz1WkOYvFRcFDGdiOAbg4d8xt8OGSGR2nMNU4I1n5MDE1RBPBqRd+WVJ5w7q3pwMoZA==} - abbrev@3.0.1: - resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} - engines: {node: ^18.17.0 || >=20.5.0} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -3047,11 +3073,11 @@ packages: peerDependencies: ajv: ^6.9.1 - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} @@ -3136,8 +3162,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.10: - resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==} + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} engines: {node: '>=6.0.0'} hasBin: true @@ -3151,8 +3177,8 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bippy@0.5.32: - resolution: {integrity: sha512-yt1mC8eReTxjfg41YBZdN4PvsDwHFWxltoiQX0Q+Htlbf41aSniopb7ECZits01HwNAvXEh69RGk/ImlswDTEw==} + bippy@0.5.39: + resolution: {integrity: sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w==} peerDependencies: react: '>=17.0.1' @@ -3167,22 +3193,22 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3218,10 +3244,6 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cacache@19.0.1: - resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} - engines: {node: ^18.17.0 || >=20.5.0} - cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -3242,8 +3264,12 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001780: - resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3272,8 +3298,8 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chevrotain-allstar@0.4.1: - resolution: {integrity: sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==} + chevrotain-allstar@0.4.3: + resolution: {integrity: sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==} peerDependencies: chevrotain: ^12.0.0 @@ -3302,10 +3328,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3314,6 +3336,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -3326,6 +3352,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3333,10 +3362,6 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3401,8 +3426,8 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} content-type@1.0.5: @@ -3478,8 +3503,8 @@ packages: peerDependencies: cytoscape: ^3.2.0 - cytoscape@3.33.2: - resolution: {integrity: sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==} + cytoscape@3.33.3: + resolution: {integrity: sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==} engines: {node: '>=0.10'} d3-array@2.12.1: @@ -3640,6 +3665,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3671,9 +3700,6 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -3718,10 +3744,13 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff@8.0.3: - resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -3737,8 +3766,8 @@ packages: dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} - dompurify@3.4.0: - resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dompurify@3.4.2: + resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} @@ -3748,17 +3777,14 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.3.1: - resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -3782,8 +3808,8 @@ packages: electron-publish@26.8.1: resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} - electron-to-chromium@1.5.321: - resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + electron-to-chromium@1.5.351: + resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==} electron-updater@6.8.3: resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} @@ -3803,8 +3829,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.3.0: - resolution: {integrity: sha512-2Q5aeocmFdeheZGDUTrAvSR3t+n0c3d104AJWWEnt7syJU0tE4VdibMYaPtQ47QuXSoUf0/xSsfUUvu/uSXIfg==} + electron@41.5.0: + resolution: {integrity: sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg==} engines: {node: '>= 12.20.55'} hasBin: true @@ -3814,21 +3840,15 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - encoding@0.1.13: - resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} entities@6.0.1: @@ -3857,8 +3877,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -3876,8 +3896,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true @@ -3914,8 +3934,8 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -3941,8 +3961,8 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express-rate-limit@8.3.1: - resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + express-rate-limit@8.5.0: + resolution: {integrity: sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -3977,8 +3997,17 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4020,9 +4049,9 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} @@ -4067,10 +4096,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -4090,6 +4115,9 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + fzf@0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4140,11 +4168,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -4168,8 +4191,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.1: - resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} hachure-fill@0.5.2: @@ -4190,8 +4213,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} hast-util-from-dom@5.0.1: @@ -4239,23 +4262,23 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} highlight.js@11.11.1: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.8: - resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + hono@4.12.17: + resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} - hosted-git-info@9.0.2: - resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} html-to-image@1.11.13: @@ -4327,10 +4350,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -4404,10 +4423,6 @@ packages: engines: {node: '>=14.16'} hasBin: true - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -4442,10 +4457,6 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-unicode-supported@1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} @@ -4473,20 +4484,21 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} hasBin: true - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} @@ -4532,8 +4544,8 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} katex@0.16.45: resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} @@ -4553,8 +4565,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - langium@4.2.2: - resolution: {integrity: sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==} + langium@4.2.3: + resolution: {integrity: sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==} engines: {node: '>=20.10.0', npm: '>=10.2.3'} layout-base@1.0.2: @@ -4655,6 +4667,10 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -4665,20 +4681,17 @@ packages: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -4693,11 +4706,8 @@ packages: lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@11.2.7: - resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -4715,10 +4725,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - make-fetch-happen@14.0.3: - resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} - engines: {node: ^18.17.0 || >=20.5.0} - markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -4732,8 +4738,8 @@ packages: engines: {node: '>= 20'} hasBin: true - marked@17.0.5: - resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==} + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} engines: {node: '>= 20'} hasBin: true @@ -4948,8 +4954,8 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -4966,30 +4972,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass-collect@2.0.1: - resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass-fetch@4.0.1: - resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass-sized@1.0.3: - resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -5014,8 +4996,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.12.14: - resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} + msw@2.14.3: + resolution: {integrity: sha512-kk8G5cocVlJ4wsKMGZegn2H6XLOEKjbA+nSJE2354e/SRp4mDicCHUYnMXpymzVcVDCs+GUAsmNqSn+yHv4T2A==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -5024,15 +5006,15 @@ packages: typescript: optional: true - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} nan@2.26.2: resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -5043,12 +5025,12 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - node-abi@3.89.0: - resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + node-abi@3.91.0: + resolution: {integrity: sha512-B+S7X/GS3Un6wMICtnsNjQD7oSpVBQrZftHE6GZ1Fe9/k3XOOoqbM5DZZ0GO4x3YiSCQfrM28yj1ppplwgIsfg==} engines: {node: '>=10'} - node-abi@4.28.0: - resolution: {integrity: sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==} + node-abi@4.30.0: + resolution: {integrity: sha512-D7v3slBz2jgsBLCY9FnFsD0nlXvuKd49Pl9L+Vj+7ZlivHsLK2mkekQo7vZwYco+kyb4OJlaWfYdr3+27vfYtA==} engines: {node: '>=22.12.0'} node-addon-api@1.7.2: @@ -5069,23 +5051,20 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp@11.5.0: - resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} - engines: {node: ^18.17.0 || >=20.5.0} + node-gyp@12.3.0: + resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} - node-readable-to-web-readable-stream@0.4.2: - resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} - - nopt@8.1.0: - resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} - engines: {node: ^18.17.0 || >=20.5.0} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true normalize-url@6.1.0: @@ -5142,14 +5121,14 @@ packages: resolution: {integrity: sha512-FQHR4oGP+a0m/f6yHoRpBOIbn/5ZWxKd4D/djHVJu8+KpBTYrJda0b7mLcgDEMWXE9xBCJm+qb0yv6FcvPjukg==} hasBin: true - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} @@ -5161,12 +5140,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint@1.56.0: - resolution: {integrity: sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==} + oxlint@1.63.0: + resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.15.0' + oxlint-tsgolint: '>=0.22.1' peerDependenciesMeta: oxlint-tsgolint: optional: true @@ -5175,20 +5154,25 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -5221,6 +5205,10 @@ packages: path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -5233,22 +5221,18 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pdfjs-dist@5.6.205: - resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==} - engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + pdfjs-dist@5.7.284: + resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==} + engines: {node: '>=22.13.0 || >=24'} pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} @@ -5260,12 +5244,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pkce-challenge@5.0.1: @@ -5289,6 +5273,14 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -5303,12 +5295,12 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} - posthog-node@5.33.0: - resolution: {integrity: sha512-9Sj1kKQTFlHoiWl0LvD3+8InhlD9jxeIiJrMLtU87bnYuQUDqAVR16v9/ZGaQ9C0vDVGVJJQG1a6XYeySy9hmg==} + posthog-node@5.33.3: + resolution: {integrity: sha512-6BkdRtRKf/y/j6JGXLUDWpruL9Rebpe2EtbSU3EB+yaI9ukTwEGT8XJQDyM8wcsxu1HdOnvAwN7gnOOu5OgY2A==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -5335,9 +5327,9 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - proc-log@5.0.0: - resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} - engines: {node: ^18.17.0 || >=20.5.0} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} @@ -5357,8 +5349,8 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - prosemirror-changeset@2.4.0: - resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==} + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} @@ -5404,8 +5396,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -5440,13 +5437,13 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: - react: ^19.2.4 + react: ^19.2.5 - react-grab@0.1.28: - resolution: {integrity: sha512-u3fvu7a7ejHhuWKzf/N6sFavV04vcqwtbcqyxwNPtvd3ts9KSVerpHp1ZH0/XVKTsh3MZz1u1jyIt0KYC9L/Rg==} + react-grab@0.1.33: + resolution: {integrity: sha512-ER919JMsE4TTrb2CpEivqsIjNMSycD4HtS8v7mS3pq67U7WL1K3+C8m9AYOwW4dpuYh+EanC2eJBmfuczHJZ0A==} hasBin: true peerDependencies: react: '>=17.0.0' @@ -5494,8 +5491,8 @@ packages: '@types/react': optional: true - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} read-binary-file-arch@1.0.6: @@ -5554,6 +5551,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -5568,10 +5568,6 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -5584,8 +5580,8 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} - rettime@0.10.1: - resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -5606,8 +5602,8 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rollup@4.59.1: - resolution: {integrity: sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==} + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -5671,25 +5667,21 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} - seroval-plugins@1.5.1: - resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} - engines: {node: '>=10'} - peerDependencies: - seroval: ^1.0 - - seroval@1.5.1: - resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} - engines: {node: '>=10'} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.1.0: - resolution: {integrity: sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==} + shadcn@4.7.0: + resolution: {integrity: sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w==} hasBin: true shebang-command@2.0.0: @@ -5700,8 +5692,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -5732,8 +5724,8 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-git@3.33.0: - resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} + simple-git@3.36.0: + resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} @@ -5758,21 +5750,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.6.0: - resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - - solid-js@1.9.11: - resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} - sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -5800,10 +5781,6 @@ packages: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} - ssri@12.0.0: - resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} - engines: {node: ^18.17.0 || >=20.5.0} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -5818,13 +5795,17 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -5836,16 +5817,12 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} string_decoder@1.3.0: @@ -5888,8 +5865,8 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - stylis@4.3.6: - resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} @@ -5906,11 +5883,11 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} tar-fs@2.1.4: @@ -5920,8 +5897,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@7.5.12: - resolution: {integrity: sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==} + tar@7.5.14: + resolution: {integrity: sha512-/7sHKgQO3JLP9ESlwTYUUftHUadOURUqq23xs1vjcnp8Vss6k0wCfzulyEtk5g91pjvnuriimGlyG7k6msrzRw==} engines: {node: '>=18'} temp-file@3.4.0: @@ -5946,12 +5923,12 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.4: - resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} tinypool@2.1.0: @@ -5962,11 +5939,11 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.0.27: - resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} - tldts@7.0.27: - resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true tmp-promise@3.0.3: @@ -6020,12 +5997,15 @@ packages: tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@5.5.0: - resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} type-is@2.0.1: @@ -6037,8 +6017,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -6046,8 +6026,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} @@ -6056,14 +6040,6 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - unique-filename@4.0.0: - resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - unique-slug@5.0.0: - resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} - engines: {node: ^18.17.0 || >=20.5.0} - unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -6140,8 +6116,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true validate-npm-package-name@7.0.2: @@ -6165,8 +6141,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6205,18 +6181,20 @@ packages: yaml: optional: true - vitest@4.1.1: - resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.1 - '@vitest/browser-preview': 4.1.1 - '@vitest/browser-webdriverio': 4.1.1 - '@vitest/ui': 4.1.1 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6233,6 +6211,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -6263,9 +6245,6 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -6273,6 +6252,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -6288,6 +6270,11 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -6301,10 +6288,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -6332,6 +6315,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6346,15 +6332,23 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -6366,27 +6360,27 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} + yocto-spinner@1.2.0: + resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==} + engines: {node: '>=18.19'} yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: - zod: ^3.25 || ^4 + zod: ^3.25.28 || ^4 zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zustand@5.0.12: - resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + zustand@5.0.13: + resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -6413,9 +6407,14 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 - tinyexec: 1.0.4 + tinyexec: 1.1.2 - '@antfu/ni@0.23.2': {} + '@antfu/ni@30.1.0': + dependencies: + fzf: 0.5.2 + package-manager-detector: 1.6.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 '@babel/code-frame@7.29.0': dependencies: @@ -6423,7 +6422,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': dependencies: @@ -6432,7 +6431,7 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -6447,7 +6446,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -6459,13 +6458,13 @@ snapshots: '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 @@ -6536,7 +6535,7 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.29.2': + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -6577,7 +6576,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) @@ -6598,7 +6597,7 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -6606,7 +6605,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -6637,61 +6636,62 @@ snapshots: '@develar/schema-utils@2.6.5': dependencies: - ajv: 6.14.0 - ajv-keywords: 3.5.2(ajv@6.14.0) + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) - '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + '@dnd-kit/accessibility@3.1.1(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@dnd-kit/accessibility': 3.1.1(react@19.2.5) + '@dnd-kit/utilities': 3.2.2(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 + '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@dnd-kit/utilities': 3.2.2(react@19.2.5) + react: 19.2.5 tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.2.4)': + '@dnd-kit/utilities@3.2.2(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 - '@dotenvx/dotenvx@1.57.0': + '@dotenvx/dotenvx@1.65.0': dependencies: commander: 11.1.0 - dotenv: 17.3.1 + dotenv: 17.4.2 eciesjs: 0.4.18 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.3 + picomatch: 4.0.4 which: 4.0.0 + yocto-spinner: 1.2.0 - '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 - '@electron-toolkit/preload@3.0.2(electron@41.3.0)': + '@electron-toolkit/preload@3.0.2(electron@41.5.0)': dependencies: - electron: 41.3.0 + electron: 41.5.0 - '@electron-toolkit/tsconfig@2.0.0(@types/node@25.5.0)': + '@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@electron-toolkit/utils@4.0.0(electron@41.3.0)': + '@electron-toolkit/utils@4.0.0(electron@41.5.0)': dependencies: - electron: 41.3.0 + electron: 41.5.0 '@electron/asar@3.4.1': dependencies: @@ -6752,21 +6752,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/rebuild@4.0.3': + '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 - detect-libc: 2.1.2 - got: 11.8.6 - graceful-fs: 4.2.11 - node-abi: 4.28.0 + node-abi: 4.30.0 node-api-version: 0.2.1 - node-gyp: 11.5.0 - ora: 5.4.1 + node-gyp: 12.3.0 read-binary-file-arch: 1.0.6 - semver: 7.7.4 - tar: 7.5.12 - yargs: 17.7.2 transitivePeerDependencies: - supports-color @@ -6796,157 +6789,157 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.27.4': + '@esbuild/aix-ppc64@0.27.7': optional: true '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.27.4': + '@esbuild/android-arm64@0.27.7': optional: true '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.27.4': + '@esbuild/android-arm@0.27.7': optional: true '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.27.4': + '@esbuild/android-x64@0.27.7': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.27.4': + '@esbuild/darwin-arm64@0.27.7': optional: true '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.27.4': + '@esbuild/darwin-x64@0.27.7': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.4': + '@esbuild/freebsd-arm64@0.27.7': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.27.4': + '@esbuild/freebsd-x64@0.27.7': optional: true '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.27.4': + '@esbuild/linux-arm64@0.27.7': optional: true '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.27.4': + '@esbuild/linux-arm@0.27.7': optional: true '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.27.4': + '@esbuild/linux-ia32@0.27.7': optional: true '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.27.4': + '@esbuild/linux-loong64@0.27.7': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.27.4': + '@esbuild/linux-mips64el@0.27.7': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.27.4': + '@esbuild/linux-ppc64@0.27.7': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.27.4': + '@esbuild/linux-riscv64@0.27.7': optional: true '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.27.4': + '@esbuild/linux-s390x@0.27.7': optional: true '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.27.4': + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.27.4': + '@esbuild/netbsd-arm64@0.27.7': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.27.4': + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.27.4': + '@esbuild/openbsd-arm64@0.27.7': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.27.4': + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.4': + '@esbuild/openharmony-arm64@0.27.7': optional: true '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.27.4': + '@esbuild/sunos-x64@0.27.7': optional: true '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.27.4': + '@esbuild/win32-arm64@0.27.7': optional: true '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.27.4': + '@esbuild/win32-ia32@0.27.7': optional: true '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.27.4': + '@esbuild/win32-x64@0.27.7': optional: true '@floating-ui/core@1.7.5': @@ -6958,66 +6951,56 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@floating-ui/dom': 1.7.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) '@floating-ui/utils@0.2.11': {} - '@graphql-typed-document-node/core@3.2.0(graphql@16.13.1)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.13.2)': dependencies: - graphql: 16.13.1 + graphql: 16.13.2 - '@hono/node-server@1.19.11(hono@4.12.8)': + '@hono/node-server@1.19.14(hono@4.12.17)': dependencies: - hono: 4.12.8 + hono: 4.12.17 '@iconify/types@2.0.0': {} - '@iconify/utils@3.1.0': + '@iconify/utils@3.1.1': dependencies: '@antfu/install-pkg': 1.1.0 '@iconify/types': 2.0.0 mlly: 1.8.2 - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@2.0.5': {} - '@inquirer/confirm@5.1.21(@types/node@25.5.0)': + '@inquirer/confirm@6.0.12(@types/node@25.6.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.5.0) - '@inquirer/type': 3.0.10(@types/node@25.5.0) + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@inquirer/core@10.3.2(@types/node@25.5.0)': + '@inquirer/core@11.1.9(@types/node@25.6.0)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.5.0) + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.0) cli-width: 4.1.0 - mute-stream: 2.0.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@2.0.5': {} - '@inquirer/type@3.0.10(@types/node@25.5.0)': + '@inquirer/type@4.0.5(@types/node@25.6.0)': optionalDependencies: - '@types/node': 25.5.0 - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 + '@types/node': 25.6.0 '@isaacs/fs-minipass@4.0.1': dependencies: @@ -7050,9 +7033,9 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} - '@linear/sdk@82.1.0(graphql@16.13.1)': + '@linear/sdk@82.1.0(graphql@16.13.2)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.1) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) transitivePeerDependencies: - graphql @@ -7064,36 +7047,34 @@ snapshots: dependencies: debug: 4.4.3 fs-extra: 9.1.0 - lodash: 4.17.23 + lodash: 4.18.1 tmp-promise: 3.0.3 transitivePeerDependencies: - supports-color - '@medv/finder@4.0.2': {} - '@mermaid-js/parser@1.1.0': dependencies: - langium: 4.2.2 + langium: 4.2.3 - '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + '@hono/node-server': 1.19.14(hono@4.12.17) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.0.8 express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 - jose: 6.2.2 + express-rate-limit: 8.5.0(express@5.2.1) + hono: 4.12.17 + jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -7101,14 +7082,14 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@monaco-editor/loader': 1.7.0 monaco-editor: 0.55.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@mswjs/interceptors@0.41.3': + '@mswjs/interceptors@0.41.8': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -7117,52 +7098,52 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/canvas-android-arm64@0.1.99': + '@napi-rs/canvas-android-arm64@0.1.100': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.99': + '@napi-rs/canvas-darwin-arm64@0.1.100': optional: true - '@napi-rs/canvas-darwin-x64@0.1.99': + '@napi-rs/canvas-darwin-x64@0.1.100': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.99': + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.99': + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.99': + '@napi-rs/canvas-linux-arm64-musl@0.1.100': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.99': + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.99': + '@napi-rs/canvas-linux-x64-gnu@0.1.100': optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.99': + '@napi-rs/canvas-linux-x64-musl@0.1.100': optional: true - '@napi-rs/canvas-win32-arm64-msvc@0.1.99': + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.99': + '@napi-rs/canvas-win32-x64-msvc@0.1.100': optional: true - '@napi-rs/canvas@0.1.99': + '@napi-rs/canvas@0.1.100': optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.99 - '@napi-rs/canvas-darwin-arm64': 0.1.99 - '@napi-rs/canvas-darwin-x64': 0.1.99 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.99 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.99 - '@napi-rs/canvas-linux-arm64-musl': 0.1.99 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.99 - '@napi-rs/canvas-linux-x64-gnu': 0.1.99 - '@napi-rs/canvas-linux-x64-musl': 0.1.99 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.99 - '@napi-rs/canvas-win32-x64-msvc': 0.1.99 + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 optional: true '@noble/ciphers@1.3.0': {} @@ -7185,22 +7166,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@npmcli/agent@3.0.0': - dependencies: - agent-base: 7.1.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 10.4.3 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - '@npmcli/fs@4.0.0': - dependencies: - semver: 7.7.4 - '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@3.0.0': {} + '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 @@ -7265,61 +7234,61 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.41.0': optional: true - '@oxlint/binding-android-arm-eabi@1.56.0': + '@oxlint/binding-android-arm-eabi@1.63.0': optional: true - '@oxlint/binding-android-arm64@1.56.0': + '@oxlint/binding-android-arm64@1.63.0': optional: true - '@oxlint/binding-darwin-arm64@1.56.0': + '@oxlint/binding-darwin-arm64@1.63.0': optional: true - '@oxlint/binding-darwin-x64@1.56.0': + '@oxlint/binding-darwin-x64@1.63.0': optional: true - '@oxlint/binding-freebsd-x64@1.56.0': + '@oxlint/binding-freebsd-x64@1.63.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.56.0': + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.56.0': + '@oxlint/binding-linux-arm-musleabihf@1.63.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.56.0': + '@oxlint/binding-linux-arm64-gnu@1.63.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.56.0': + '@oxlint/binding-linux-arm64-musl@1.63.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.56.0': + '@oxlint/binding-linux-ppc64-gnu@1.63.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.56.0': + '@oxlint/binding-linux-riscv64-gnu@1.63.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.56.0': + '@oxlint/binding-linux-riscv64-musl@1.63.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.56.0': + '@oxlint/binding-linux-s390x-gnu@1.63.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.56.0': + '@oxlint/binding-linux-x64-gnu@1.63.0': optional: true - '@oxlint/binding-linux-x64-musl@1.56.0': + '@oxlint/binding-linux-x64-musl@1.63.0': optional: true - '@oxlint/binding-openharmony-arm64@1.56.0': + '@oxlint/binding-openharmony-arm64@1.63.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.56.0': + '@oxlint/binding-win32-arm64-msvc@1.63.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.56.0': + '@oxlint/binding-win32-ia32-msvc@1.63.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.56.0': + '@oxlint/binding-win32-x64-msvc@1.63.0': optional: true '@parcel/watcher-android-arm64@2.5.6': @@ -7366,7 +7335,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -7382,881 +7351,900 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 - '@pkgjs/parseargs@0.11.0': - optional: true - '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 - '@posthog/core@1.28.0': + '@posthog/core@1.28.3': dependencies: - '@posthog/types': 1.372.6 + '@posthog/types': 1.372.9 - '@posthog/types@1.372.6': {} + '@posthog/types@1.372.9': {} '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) '@radix-ui/rect': 1.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.2.4 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) '@radix-ui/rect@1.1.1': {} - '@react-grab/cli@0.1.28': + '@react-grab/cli@0.1.33': dependencies: - '@antfu/ni': 0.23.2 + '@antfu/ni': 30.1.0 commander: 14.0.3 ignore: 7.0.5 jsonc-parser: 3.3.1 - ora: 8.2.0 + ora: 9.4.0 picocolors: 1.1.1 prompts: 2.4.2 - smol-toml: 1.6.0 + smol-toml: 1.6.1 '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.59.1': + '@rollup/rollup-android-arm-eabi@4.60.3': optional: true - '@rollup/rollup-android-arm64@4.59.1': + '@rollup/rollup-android-arm64@4.60.3': optional: true - '@rollup/rollup-darwin-arm64@4.59.1': + '@rollup/rollup-darwin-arm64@4.60.3': optional: true - '@rollup/rollup-darwin-x64@4.59.1': + '@rollup/rollup-darwin-x64@4.60.3': optional: true - '@rollup/rollup-freebsd-arm64@4.59.1': + '@rollup/rollup-freebsd-arm64@4.60.3': optional: true - '@rollup/rollup-freebsd-x64@4.59.1': + '@rollup/rollup-freebsd-x64@4.60.3': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.59.1': + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.59.1': + '@rollup/rollup-linux-arm-musleabihf@4.60.3': optional: true - '@rollup/rollup-linux-arm64-gnu@4.59.1': + '@rollup/rollup-linux-arm64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-arm64-musl@4.59.1': + '@rollup/rollup-linux-arm64-musl@4.60.3': optional: true - '@rollup/rollup-linux-loong64-gnu@4.59.1': + '@rollup/rollup-linux-loong64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-loong64-musl@4.59.1': + '@rollup/rollup-linux-loong64-musl@4.60.3': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.59.1': + '@rollup/rollup-linux-ppc64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-ppc64-musl@4.59.1': + '@rollup/rollup-linux-ppc64-musl@4.60.3': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.59.1': + '@rollup/rollup-linux-riscv64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-riscv64-musl@4.59.1': + '@rollup/rollup-linux-riscv64-musl@4.60.3': optional: true - '@rollup/rollup-linux-s390x-gnu@4.59.1': + '@rollup/rollup-linux-s390x-gnu@4.60.3': optional: true - '@rollup/rollup-linux-x64-gnu@4.59.1': + '@rollup/rollup-linux-x64-gnu@4.60.3': optional: true - '@rollup/rollup-linux-x64-musl@4.59.1': + '@rollup/rollup-linux-x64-musl@4.60.3': optional: true - '@rollup/rollup-openbsd-x64@4.59.1': + '@rollup/rollup-openbsd-x64@4.60.3': optional: true - '@rollup/rollup-openharmony-arm64@4.59.1': + '@rollup/rollup-openharmony-arm64@4.60.3': optional: true - '@rollup/rollup-win32-arm64-msvc@4.59.1': + '@rollup/rollup-win32-arm64-msvc@4.60.3': optional: true - '@rollup/rollup-win32-ia32-msvc@4.59.1': + '@rollup/rollup-win32-ia32-msvc@4.60.3': optional: true - '@rollup/rollup-win32-x64-gnu@4.59.1': + '@rollup/rollup-win32-x64-gnu@4.60.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.59.1': + '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true '@sec-ant/readable-stream@0.4.1': {} + '@simple-git/args-pathspec@1.0.3': {} + + '@simple-git/argv-parser@1.1.1': + dependencies: + '@simple-git/args-pathspec': 1.0.3 + '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} - '@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)': + '@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.4.3)': dependencies: '@playwright/test': 1.59.1 jpeg-js: 0.4.4 p-retry: 4.6.2 pngjs: 7.0.0 optionalDependencies: - zod: 4.3.6 + zod: 4.4.3 - '@stablyai/playwright-test@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)': + '@stablyai/playwright-test@2.1.14(@playwright/test@1.59.1)(zod@4.4.3)': dependencies: '@playwright/test': 1.59.1 - '@stablyai/playwright': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6) - '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6) + '@stablyai/playwright': 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) + '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) transitivePeerDependencies: - zod - '@stablyai/playwright@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)': + '@stablyai/playwright@2.1.14(@playwright/test@1.59.1)(zod@4.4.3)': dependencies: '@playwright/test': 1.59.1 - '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6) + '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) transitivePeerDependencies: - zod @@ -8266,244 +8254,244 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/node@4.2.2': + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 + enhanced-resolve: 5.21.0 + jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/oxide-android-arm64@4.2.2': + '@tailwindcss/oxide-android-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.2': + '@tailwindcss/oxide-darwin-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.2': + '@tailwindcss/oxide-darwin-x64@4.2.4': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.2': + '@tailwindcss/oxide-freebsd-x64@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.2': + '@tailwindcss/oxide-linux-x64-musl@4.2.4': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.2': + '@tailwindcss/oxide-wasm32-wasi@4.2.4': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': optional: true - '@tailwindcss/oxide@4.2.2': + '@tailwindcss/oxide@4.2.4': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) - '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/virtual-core': 3.13.23 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@tanstack/virtual-core': 3.14.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) - '@tanstack/virtual-core@3.13.23': {} + '@tanstack/virtual-core@3.14.0': {} - '@tiptap/core@3.22.4(@tiptap/pm@3.22.4)': + '@tiptap/core@3.22.5(@tiptap/pm@3.22.5)': dependencies: - '@tiptap/pm': 3.22.4 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-blockquote@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-blockquote@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-bold@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-bold@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-bubble-menu@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/extension-bubble-menu@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 optional: true - '@tiptap/extension-bullet-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-bullet-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-code-block-lowlight@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(highlight.js@11.11.1)(lowlight@3.3.0)': + '@tiptap/extension-code-block-lowlight@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-code-block': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 highlight.js: 11.11.1 lowlight: 3.3.0 - '@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 - '@tiptap/extension-code@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-code@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-document@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-document@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-dropcursor@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-dropcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-floating-menu@3.22.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/extension-floating-menu@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 optional: true - '@tiptap/extension-gapcursor@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-gapcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-hard-break@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-hard-break@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-heading@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-heading@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-horizontal-rule@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/extension-horizontal-rule@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 - '@tiptap/extension-image@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-image@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-italic@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-italic@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-link@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/extension-link@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 linkifyjs: 4.3.2 - '@tiptap/extension-list-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-list-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-list-keymap@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-list-keymap@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 - '@tiptap/extension-ordered-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-paragraph@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-paragraph@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-placeholder@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-strike@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-table-cell@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-table-cell@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-table-header@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-table-header@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-table-row@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-table-row@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 - '@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-task-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-task-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-text@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-underline@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': + '@tiptap/extension-underline@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 - '@tiptap/markdown@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + '@tiptap/markdown@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 - marked: 17.0.5 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + marked: 17.0.6 - '@tiptap/pm@3.22.4': + '@tiptap/pm@3.22.5': dependencies: - prosemirror-changeset: 2.4.0 + prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 prosemirror-dropcursor: 1.8.2 prosemirror-gapcursor: 1.4.1 @@ -8516,59 +8504,59 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.8 - '@tiptap/react@3.22.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tiptap/react@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/use-sync-external-store': 0.0.6 fast-equals: 5.4.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - use-sync-external-store: 1.6.0(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-floating-menu': 3.22.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-bubble-menu': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-floating-menu': 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.22.4': + '@tiptap/starter-kit@3.22.5': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-blockquote': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-bold': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-bullet-list': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) - '@tiptap/extension-code': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-code-block': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-document': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-dropcursor': 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) - '@tiptap/extension-gapcursor': 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) - '@tiptap/extension-hard-break': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-heading': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-horizontal-rule': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-italic': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-link': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-list-item': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) - '@tiptap/extension-list-keymap': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) - '@tiptap/extension-ordered-list': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) - '@tiptap/extension-paragraph': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-strike': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-text': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extension-underline': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) - '@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-blockquote': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-bold': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-bullet-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-code': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-document': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-dropcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-gapcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-hard-break': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-heading': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-horizontal-rule': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-italic': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-link': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list-item': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-list-keymap': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-ordered-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-paragraph': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-strike': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-text': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-underline': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 - minimatch: 10.2.4 + minimatch: 10.2.5 path-browserify: 1.0.1 '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 @@ -8580,7 +8568,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': @@ -8589,13 +8577,13 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -8734,7 +8722,7 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/geojson@7946.0.16': {} @@ -8748,7 +8736,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/mdast@4.0.4': dependencies: @@ -8764,21 +8752,21 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.5.0': + '@types/node@25.6.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.19.2 '@types/plist@3.0.5': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 xmlbuilder: 15.1.1 optional: true - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/qrcode@1.5.6': dependencies: - '@types/react': 19.2.14 + '@types/node': 25.6.0 - '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -8788,10 +8776,14 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/retry@0.12.0': {} + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.6.0 + '@types/ssh2@1.15.5': dependencies: '@types/node': 18.19.130 @@ -8814,11 +8806,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 optional: true '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260406.1': @@ -8852,14 +8844,14 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260406.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260406.1 - '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.3.1': {} '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -8867,89 +8859,92 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color - '@vitest/expect@4.1.1': + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.1 - '@vitest/utils': 4.1.1 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.1(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: - '@vitest/spy': 4.1.1 + '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3) - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + msw: 2.14.3(@types/node@25.6.0)(typescript@5.9.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) - '@vitest/pretty-format@4.1.1': + '@vitest/pretty-format@4.1.5': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.1': + '@vitest/runner@4.1.5': dependencies: - '@vitest/utils': 4.1.1 + '@vitest/utils': 4.1.5 pathe: 2.0.3 - '@vitest/snapshot@4.1.1': + '@vitest/snapshot@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.1 - '@vitest/utils': 4.1.1 + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.1': {} + '@vitest/spy@4.1.5': {} - '@vitest/utils@4.1.1': + '@vitest/utils@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.1 + '@vitest/pretty-format': 4.1.5 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.8.13': {} - '@xterm/addon-fit@0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198)': - dependencies: - '@xterm/xterm': 6.1.0-beta.198 + '@xmldom/xmldom@0.9.10': + optional: true - '@xterm/addon-ligatures@0.11.0-beta.198(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.198)': + '@xterm/addon-fit@0.12.0-beta.216(@xterm/xterm@6.1.0-beta.216)': dependencies: - '@xterm/xterm': 6.1.0-beta.198 + '@xterm/xterm': 6.1.0-beta.216 + + '@xterm/addon-ligatures@0.11.0-beta.216(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.216)': + dependencies: + '@xterm/xterm': 6.1.0-beta.216 lru-cache: 6.0.0 opentype.js: 0.8.0 - '@xterm/addon-search@0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + '@xterm/addon-search@0.17.0-beta.216(@xterm/xterm@6.1.0-beta.216)': dependencies: - '@xterm/xterm': 6.1.0-beta.198 + '@xterm/xterm': 6.1.0-beta.216 - '@xterm/addon-serialize@0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + '@xterm/addon-serialize@0.15.0-beta.216(@xterm/xterm@6.1.0-beta.216)': dependencies: - '@xterm/xterm': 6.1.0-beta.198 + '@xterm/xterm': 6.1.0-beta.216 - '@xterm/addon-unicode11@0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + '@xterm/addon-unicode11@0.10.0-beta.216(@xterm/xterm@6.1.0-beta.216)': dependencies: - '@xterm/xterm': 6.1.0-beta.198 + '@xterm/xterm': 6.1.0-beta.216 - '@xterm/addon-web-links@0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + '@xterm/addon-web-links@0.13.0-beta.216(@xterm/xterm@6.1.0-beta.216)': dependencies: - '@xterm/xterm': 6.1.0-beta.198 + '@xterm/xterm': 6.1.0-beta.216 - '@xterm/addon-webgl@0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198)': + '@xterm/addon-webgl@0.20.0-beta.215(@xterm/xterm@6.1.0-beta.216)': dependencies: - '@xterm/xterm': 6.1.0-beta.198 + '@xterm/xterm': 6.1.0-beta.216 - '@xterm/headless@6.1.0-beta.198': {} + '@xterm/headless@6.1.0-beta.216': {} - '@xterm/xterm@6.1.0-beta.198': {} + '@xterm/xterm@6.1.0-beta.216': {} - abbrev@3.0.1: {} + abbrev@4.0.0: {} accepts@2.0.0: dependencies: @@ -8962,25 +8957,25 @@ snapshots: agent-browser@0.24.1: {} - ajv-formats@3.0.1(ajv@8.18.0): + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: - ajv: 8.18.0 + ajv: 8.20.0 - ajv-keywords@3.5.2(ajv@6.14.0): + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: - ajv: 6.14.0 + ajv: 6.15.0 - ajv@6.14.0: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -9008,7 +9003,7 @@ snapshots: '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 '@electron/osx-sign': 1.3.3 - '@electron/rebuild': 4.0.3 + '@electron/rebuild': 4.0.4 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 '@types/fs-extra': 9.0.13 @@ -9027,16 +9022,16 @@ snapshots: fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 - jiti: 2.6.1 + jiti: 2.7.0 js-yaml: 4.1.1 json5: 2.2.3 lazy-val: 1.0.5 - minimatch: 10.2.4 + minimatch: 10.2.5 plist: 3.1.0 proper-lockfile: 4.1.2 resedit: 1.7.2 semver: 7.7.4 - tar: 7.5.12 + tar: 7.5.14 temp-file: 3.4.0 tiny-async-pool: 1.3.0 which: 5.0.0 @@ -9081,7 +9076,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.10: {} + baseline-browser-mapping@2.10.27: {} bcrypt-pbkdf@1.0.2: dependencies: @@ -9096,12 +9091,9 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 - bippy@0.5.32(@types/react@19.2.14)(react@19.2.4): + bippy@0.5.39(react@19.2.5): dependencies: - '@types/react-reconciler': 0.28.9(@types/react@19.2.14) - react: 19.2.4 - transitivePeerDependencies: - - '@types/react' + react: 19.2.5 bl@4.1.0: dependencies: @@ -9117,7 +9109,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.0 + qs: 6.15.1 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -9126,16 +9118,16 @@ snapshots: boolean@3.2.0: optional: true - brace-expansion@1.1.12: + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -9143,13 +9135,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.10 - caniuse-lite: 1.0.30001780 - electron-to-chromium: 1.5.321 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.351 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-crc32@0.2.13: {} @@ -9199,21 +9191,6 @@ snapshots: cac@6.7.14: {} - cacache@19.0.1: - dependencies: - '@npmcli/fs': 4.0.0 - fs-minipass: 3.0.3 - glob: 10.5.0 - lru-cache: 10.4.3 - minipass: 7.1.3 - minipass-collect: 2.0.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - p-map: 7.0.4 - ssri: 12.0.0 - tar: 7.5.12 - unique-filename: 4.0.0 - cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -9238,7 +9215,9 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001780: {} + camelcase@5.3.1: {} + + caniuse-lite@1.0.30001791: {} ccount@2.0.1: {} @@ -9259,7 +9238,7 @@ snapshots: character-reference-invalid@2.0.1: {} - chevrotain-allstar@0.4.1(chevrotain@12.0.0): + chevrotain-allstar@0.4.3(chevrotain@12.0.0): dependencies: chevrotain: 12.0.0 lodash-es: 4.18.1 @@ -9286,16 +9265,14 @@ snapshots: dependencies: clsx: 2.1.1 - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -9305,10 +9282,16 @@ snapshots: cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 - string-width: 8.2.0 + string-width: 8.2.1 cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -9319,18 +9302,16 @@ snapshots: dependencies: mimic-response: 1.0.1 - clone@1.0.4: {} - clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -9370,7 +9351,7 @@ snapshots: confbox@0.1.8: {} - content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -9431,17 +9412,17 @@ snapshots: csstype@3.2.3: {} - cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.2): + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.3): dependencies: cose-base: 1.0.3 - cytoscape: 3.33.2 + cytoscape: 3.33.3 - cytoscape-fcose@2.2.0(cytoscape@3.33.2): + cytoscape-fcose@2.2.0(cytoscape@3.33.3): dependencies: cose-base: 2.2.0 - cytoscape: 3.33.2 + cytoscape: 3.33.3 - cytoscape@3.33.2: {} + cytoscape@3.33.3: {} d3-array@2.12.1: dependencies: @@ -9623,6 +9604,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -9644,10 +9627,6 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 - defaults@1.0.4: - dependencies: - clone: 1.0.4 - defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -9687,7 +9666,9 @@ snapshots: dependencies: dequal: 2.0.3 - diff@8.0.3: {} + diff@8.0.4: {} + + dijkstrajs@1.0.3: {} dir-compare@4.2.0: dependencies: @@ -9711,10 +9692,10 @@ snapshots: dependencies: '@types/plist': 3.0.5 '@types/verror': 1.10.11 - ajv: 6.14.0 + ajv: 6.15.0 crc: 3.8.0 iconv-corefoundation: 1.1.7 - plist: 3.1.0 + plist: 3.1.1 smart-buffer: 4.2.0 verror: 1.10.1 optional: true @@ -9723,7 +9704,7 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - dompurify@3.4.0: + dompurify@3.4.2: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -9733,7 +9714,7 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.3.1: {} + dotenv@17.4.2: {} dunder-proto@1.0.1: dependencies: @@ -9741,11 +9722,9 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - eciesjs@0.4.18: dependencies: - '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) '@noble/ciphers': 1.3.0 '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 @@ -9794,7 +9773,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.321: {} + electron-to-chromium@1.5.351: {} electron-updater@6.8.3: dependencies: @@ -9809,7 +9788,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)): + electron-vite@5.0.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -9817,7 +9796,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -9833,7 +9812,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.3.0: + electron@41.5.0: dependencies: '@electron/get': 2.0.3 '@types/node': 24.12.2 @@ -9845,23 +9824,16 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - encodeurl@2.0.0: {} - encoding@0.1.13: - dependencies: - iconv-lite: 0.6.3 - optional: true - end-of-stream@1.4.5: dependencies: once: 1.4.0 - enhanced-resolve@5.20.1: + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 entities@6.0.1: {} @@ -9879,7 +9851,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.0.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: dependencies: @@ -9890,7 +9862,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 es6-error@4.1.1: optional: true @@ -9924,34 +9896,34 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.4: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 escalade@3.2.0: {} @@ -9974,11 +9946,11 @@ snapshots: eventemitter3@5.0.4: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.0.8: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.0.8 execa@5.1.1: dependencies: @@ -10013,7 +9985,7 @@ snapshots: exponential-backoff@3.1.3: {} - express-rate-limit@8.3.1(express@5.2.1): + express-rate-limit@8.5.0(express@5.2.1): dependencies: express: 5.2.1 ip-address: 10.1.0 @@ -10022,7 +9994,7 @@ snapshots: dependencies: accepts: 2.0.0 body-parser: 2.2.2 - content-disposition: 1.0.1 + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -10040,7 +10012,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.0 + qs: 6.15.1 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -10080,7 +10052,17 @@ snapshots: fast-json-stable-stringify@2.1.0: {} - fast-uri@3.1.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 fastq@1.20.1: dependencies: @@ -10094,9 +10076,9 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fetch-blob@3.2.0: dependencies: @@ -10128,17 +10110,17 @@ snapshots: transitivePeerDependencies: - supports-color - foreground-child@3.3.1: + find-up@4.1.0: dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 + locate-path: 5.0.0 + path-exists: 4.0.0 form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.3 mime-types: 2.1.35 format@0.2.2: {} @@ -10156,13 +10138,13 @@ snapshots: fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.0 + jsonfile: 6.2.1 universalify: 2.0.1 fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.0 + jsonfile: 6.2.1 universalify: 2.0.1 fs-extra@7.0.1: @@ -10181,13 +10163,9 @@ snapshots: dependencies: at-least-node: 1.0.0 graceful-fs: 4.2.11 - jsonfile: 6.2.0 + jsonfile: 6.2.1 universalify: 2.0.1 - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.3 - fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -10200,6 +10178,8 @@ snapshots: fuzzysort@3.1.0: {} + fzf@0.5.2: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -10216,7 +10196,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -10247,15 +10227,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -10299,7 +10270,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.1: {} + graphql@16.13.2: {} hachure-fill@0.5.2: {} @@ -10316,7 +10287,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -10369,7 +10340,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/unist': 3.0.3 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.1 hast-util-from-parse5: 8.0.3 hast-util-to-parse5: 8.0.1 html-void-elements: 3.0.0 @@ -10384,7 +10355,7 @@ snapshots: hast-util-sanitize@5.0.2: dependencies: '@types/hast': 3.0.4 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.1 unist-util-position: 5.0.0 hast-util-to-jsx-runtime@2.3.6: @@ -10440,19 +10411,22 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - headers-polyfill@4.0.3: {} + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 highlight.js@11.11.1: {} - hono@4.12.8: {} + hono@4.12.17: {} hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 - hosted-git-info@9.0.2: + hosted-git-info@9.0.3: dependencies: - lru-cache: 11.2.7 + lru-cache: 11.3.6 html-to-image@1.11.13: {} @@ -10520,8 +10494,6 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - imurmurhash@0.1.4: {} - inflight@1.0.6: dependencies: once: 1.4.0 @@ -10574,8 +10546,6 @@ snapshots: dependencies: is-docker: 3.0.0 - is-interactive@1.0.0: {} - is-interactive@2.0.0: {} is-node-process@1.2.0: {} @@ -10594,8 +10564,6 @@ snapshots: is-stream@4.0.1: {} - is-unicode-supported@0.1.0: {} - is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} @@ -10612,11 +10580,7 @@ snapshots: isexe@3.1.5: {} - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 + isexe@4.0.0: {} jake@10.9.4: dependencies: @@ -10624,9 +10588,9 @@ snapshots: filelist: 1.0.6 picocolors: 1.1.1 - jiti@2.6.1: {} + jiti@2.7.0: {} - jose@6.2.2: {} + jose@6.2.3: {} jpeg-js@0.4.4: {} @@ -10659,7 +10623,7 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonfile@6.2.0: + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: @@ -10679,11 +10643,11 @@ snapshots: kleur@4.1.5: {} - langium@4.2.2: + langium@4.2.3: dependencies: '@chevrotain/regexp-to-ast': 12.0.0 chevrotain: 12.0.0 - chevrotain-allstar: 0.4.1(chevrotain@12.0.0) + chevrotain-allstar: 0.4.3(chevrotain@12.0.0) vscode-languageserver: 9.0.1 vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.1.0 @@ -10751,10 +10715,10 @@ snapshots: dependencies: commander: 14.0.3 listr2: 9.0.5 - picomatch: 4.0.3 + picomatch: 4.0.4 string-argv: 0.3.2 - tinyexec: 1.0.4 - yaml: 2.8.3 + tinyexec: 1.1.2 + yaml: 2.8.4 listr2@9.0.5: dependencies: @@ -10765,26 +10729,28 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash-es@4.18.1: {} lodash.escaperegexp@4.1.2: {} lodash.isequal@4.5.0: {} - lodash@4.17.23: {} - lodash@4.18.1: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - log-symbols@6.0.0: dependencies: chalk: 5.6.2 is-unicode-supported: 1.3.0 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -10803,9 +10769,7 @@ snapshots: devlop: 1.1.0 highlight.js: 11.11.1 - lru-cache@10.4.3: {} - - lru-cache@11.2.7: {} + lru-cache@11.3.6: {} lru-cache@5.1.1: dependencies: @@ -10815,37 +10779,21 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@0.577.0(react@19.2.4): + lucide-react@0.577.0(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - make-fetch-happen@14.0.3: - dependencies: - '@npmcli/agent': 3.0.0 - cacache: 19.0.1 - http-cache-semantics: 4.2.0 - minipass: 7.1.3 - minipass-fetch: 4.0.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - negotiator: 1.0.0 - proc-log: 5.0.0 - promise-retry: 2.0.1 - ssri: 12.0.0 - transitivePeerDependencies: - - supports-color - markdown-table@3.0.4: {} marked@14.0.0: {} marked@16.4.2: {} - marked@17.0.5: {} + marked@17.0.6: {} matcher@3.0.0: dependencies: @@ -11011,7 +10959,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.1 devlop: 1.1.0 micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 @@ -11046,26 +10994,26 @@ snapshots: mermaid@11.14.0: dependencies: '@braintree/sanitize-url': 7.1.2 - '@iconify/utils': 3.1.0 + '@iconify/utils': 3.1.1 '@mermaid-js/parser': 1.1.0 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 - cytoscape: 3.33.2 - cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.2) - cytoscape-fcose: 2.2.0(cytoscape@3.33.2) + cytoscape: 3.33.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.3) + cytoscape-fcose: 2.2.0(cytoscape@3.33.3) d3: 7.9.0 d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.4.0 + dompurify: 3.4.2 katex: 0.16.45 khroma: 2.1.0 lodash-es: 4.18.1 marked: 16.4.2 roughjs: 4.6.6 - stylis: 4.3.6 + stylis: 4.4.0 ts-dedent: 2.2.0 - uuid: 11.1.0 + uuid: 11.1.1 micromark-core-commonmark@2.0.3: dependencies: @@ -11278,7 +11226,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -11302,52 +11250,24 @@ snapshots: mimic-response@3.1.0: {} - minimatch@10.2.4: + minimatch@10.2.5: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.5 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.14 minimatch@5.1.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.0 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.0 minimist@1.2.8: {} - minipass-collect@2.0.1: - dependencies: - minipass: 7.1.3 - - minipass-fetch@4.0.1: - dependencies: - minipass: 7.1.3 - minipass-sized: 1.0.3 - minizlib: 3.1.0 - optionalDependencies: - encoding: 0.1.13 - - minipass-flush@1.0.5: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass-sized@1.0.3: - dependencies: - minipass: 3.3.6 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - minipass@7.1.3: {} minizlib@3.1.0: @@ -11365,7 +11285,7 @@ snapshots: acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 - ufo: 1.6.3 + ufo: 1.6.4 monaco-editor@0.55.1: dependencies: @@ -11374,24 +11294,24 @@ snapshots: ms@2.1.3: {} - msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3): + msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@25.5.0) - '@mswjs/interceptors': 0.41.3 - '@open-draft/deferred-promise': 2.2.0 + '@inquirer/confirm': 6.0.12(@types/node@25.6.0) + '@mswjs/interceptors': 0.41.8 + '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.1 - headers-polyfill: 4.0.3 + graphql: 16.13.2 + headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.10.1 + rettime: 0.11.11 statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.1 - type-fest: 5.5.0 + type-fest: 5.6.0 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: @@ -11399,22 +11319,22 @@ snapshots: transitivePeerDependencies: - '@types/node' - mute-stream@2.0.0: {} + mute-stream@3.0.0: {} nan@2.26.2: optional: true - nanoid@3.3.11: {} + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} negotiator@1.0.0: {} - node-abi@3.89.0: + node-abi@3.91.0: dependencies: semver: 7.7.4 - node-abi@4.28.0: + node-abi@4.30.0: dependencies: semver: 7.7.4 @@ -11435,33 +11355,28 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-gyp@11.5.0: + node-gyp@12.3.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - make-fetch-happen: 14.0.3 - nopt: 8.1.0 - proc-log: 5.0.0 + nopt: 9.0.0 + proc-log: 6.1.0 semver: 7.7.4 - tar: 7.5.12 - tinyglobby: 0.2.15 - which: 5.0.0 - transitivePeerDependencies: - - supports-color + tar: 7.5.14 + tinyglobby: 0.2.16 + undici: 6.25.0 + which: 6.0.1 - node-pty@1.1.0(patch_hash=d39825bae630ddf0fc73e83e6001057c70957161772ba078988767d8569cee63): + node-pty@1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4): dependencies: node-addon-api: 7.1.1 - node-readable-to-web-readable-stream@0.4.2: - optional: true + node-releases@2.0.38: {} - node-releases@2.0.36: {} - - nopt@8.1.0: + nopt@9.0.0: dependencies: - abbrev: 3.0.1 + abbrev: 4.0.0 normalize-url@6.1.0: {} @@ -11514,18 +11429,6 @@ snapshots: dependencies: tiny-inflate: 1.0.3 - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - ora@8.2.0: dependencies: chalk: 5.6.2 @@ -11538,6 +11441,17 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + ora@9.4.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.1 + orderedmap@2.1.1: {} outvariant@1.4.3: {} @@ -11566,42 +11480,48 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.41.0 '@oxfmt/binding-win32-x64-msvc': 0.41.0 - oxlint@1.56.0: + oxlint@1.63.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.56.0 - '@oxlint/binding-android-arm64': 1.56.0 - '@oxlint/binding-darwin-arm64': 1.56.0 - '@oxlint/binding-darwin-x64': 1.56.0 - '@oxlint/binding-freebsd-x64': 1.56.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.56.0 - '@oxlint/binding-linux-arm-musleabihf': 1.56.0 - '@oxlint/binding-linux-arm64-gnu': 1.56.0 - '@oxlint/binding-linux-arm64-musl': 1.56.0 - '@oxlint/binding-linux-ppc64-gnu': 1.56.0 - '@oxlint/binding-linux-riscv64-gnu': 1.56.0 - '@oxlint/binding-linux-riscv64-musl': 1.56.0 - '@oxlint/binding-linux-s390x-gnu': 1.56.0 - '@oxlint/binding-linux-x64-gnu': 1.56.0 - '@oxlint/binding-linux-x64-musl': 1.56.0 - '@oxlint/binding-openharmony-arm64': 1.56.0 - '@oxlint/binding-win32-arm64-msvc': 1.56.0 - '@oxlint/binding-win32-ia32-msvc': 1.56.0 - '@oxlint/binding-win32-x64-msvc': 1.56.0 + '@oxlint/binding-android-arm-eabi': 1.63.0 + '@oxlint/binding-android-arm64': 1.63.0 + '@oxlint/binding-darwin-arm64': 1.63.0 + '@oxlint/binding-darwin-x64': 1.63.0 + '@oxlint/binding-freebsd-x64': 1.63.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 + '@oxlint/binding-linux-arm-musleabihf': 1.63.0 + '@oxlint/binding-linux-arm64-gnu': 1.63.0 + '@oxlint/binding-linux-arm64-musl': 1.63.0 + '@oxlint/binding-linux-ppc64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-musl': 1.63.0 + '@oxlint/binding-linux-s390x-gnu': 1.63.0 + '@oxlint/binding-linux-x64-gnu': 1.63.0 + '@oxlint/binding-linux-x64-musl': 1.63.0 + '@oxlint/binding-openharmony-arm64': 1.63.0 + '@oxlint/binding-win32-arm64-msvc': 1.63.0 + '@oxlint/binding-win32-ia32-msvc': 1.63.0 + '@oxlint/binding-win32-x64-msvc': 1.63.0 p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-map@7.0.4: {} + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 retry: 0.13.1 - package-json-from-dist@1.0.1: {} + p-try@2.2.0: {} package-manager-detector@1.6.0: {} @@ -11638,27 +11558,23 @@ snapshots: path-data-parser@0.1.0: {} + path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} path-key@4.0.0: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - path-to-regexp@6.3.0: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} - pdfjs-dist@5.6.205: + pdfjs-dist@5.7.284: optionalDependencies: - '@napi-rs/canvas': 0.1.99 - node-readable-to-web-readable-stream: 0.4.2 + '@napi-rs/canvas': 0.1.100 pe-library@0.4.1: {} @@ -11666,9 +11582,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pkce-challenge@5.0.1: {} @@ -11688,10 +11604,19 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 xmlbuilder: 15.1.1 + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + optional: true + + pngjs@5.0.0: {} + pngjs@7.0.0: {} points-on-curve@0.2.0: {} @@ -11706,15 +11631,15 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.8: + postcss@8.5.14: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-node@5.33.0: + posthog-node@5.33.3: dependencies: - '@posthog/core': 1.28.0 + '@posthog/core': 1.28.3 postject@1.0.0-alpha.6: dependencies: @@ -11731,7 +11656,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 2.0.0 - node-abi: 3.89.0 + node-abi: 3.91.0 pump: 3.0.4 rc: 1.2.8 simple-get: 4.0.1 @@ -11742,7 +11667,7 @@ snapshots: dependencies: parse-ms: 4.0.0 - proc-log@5.0.0: {} + proc-log@6.1.0: {} progress@2.0.3: {} @@ -11764,7 +11689,7 @@ snapshots: property-information@7.1.0: {} - prosemirror-changeset@2.4.0: + prosemirror-changeset@2.4.1: dependencies: prosemirror-transform: 1.12.0 @@ -11845,7 +11770,13 @@ snapshots: punycode@2.3.1: {} - qs@6.15.0: + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qs@6.15.1: dependencies: side-channel: 1.1.0 @@ -11853,65 +11784,65 @@ snapshots: quick-lru@5.1.1: {} - radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -11932,23 +11863,19 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-dom@19.2.4(react@19.2.4): + react-dom@19.2.5(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 scheduler: 0.27.0 - react-grab@0.1.28(@types/react@19.2.14)(react@19.2.4): + react-grab@0.1.33(react@19.2.5): dependencies: - '@medv/finder': 4.0.2 - '@react-grab/cli': 0.1.28 - bippy: 0.5.32(@types/react@19.2.14)(react@19.2.4) - solid-js: 1.9.11 + '@react-grab/cli': 0.1.33 + bippy: 0.5.39(react@19.2.5) optionalDependencies: - react: 19.2.4 - transitivePeerDependencies: - - '@types/react' + react: 19.2.5 - react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -11957,7 +11884,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.4 + react: 19.2.5 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -11968,34 +11895,34 @@ snapshots: react-refresh@0.18.0: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.5 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): dependencies: get-nonce: 1.0.1 - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react@19.2.4: {} + react@19.2.5: {} read-binary-file-arch@1.0.6: dependencies: @@ -12116,6 +12043,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -12128,11 +12057,6 @@ snapshots: dependencies: lowercase-keys: 2.0.0 - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -12142,7 +12066,7 @@ snapshots: retry@0.13.1: {} - rettime@0.10.1: {} + rettime@0.11.11: {} reusify@1.1.0: {} @@ -12164,35 +12088,35 @@ snapshots: robust-predicates@3.0.3: {} - rollup@4.59.1: + rollup@4.60.3: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.1 - '@rollup/rollup-android-arm64': 4.59.1 - '@rollup/rollup-darwin-arm64': 4.59.1 - '@rollup/rollup-darwin-x64': 4.59.1 - '@rollup/rollup-freebsd-arm64': 4.59.1 - '@rollup/rollup-freebsd-x64': 4.59.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.1 - '@rollup/rollup-linux-arm-musleabihf': 4.59.1 - '@rollup/rollup-linux-arm64-gnu': 4.59.1 - '@rollup/rollup-linux-arm64-musl': 4.59.1 - '@rollup/rollup-linux-loong64-gnu': 4.59.1 - '@rollup/rollup-linux-loong64-musl': 4.59.1 - '@rollup/rollup-linux-ppc64-gnu': 4.59.1 - '@rollup/rollup-linux-ppc64-musl': 4.59.1 - '@rollup/rollup-linux-riscv64-gnu': 4.59.1 - '@rollup/rollup-linux-riscv64-musl': 4.59.1 - '@rollup/rollup-linux-s390x-gnu': 4.59.1 - '@rollup/rollup-linux-x64-gnu': 4.59.1 - '@rollup/rollup-linux-x64-musl': 4.59.1 - '@rollup/rollup-openbsd-x64': 4.59.1 - '@rollup/rollup-openharmony-arm64': 4.59.1 - '@rollup/rollup-win32-arm64-msvc': 4.59.1 - '@rollup/rollup-win32-ia32-msvc': 4.59.1 - '@rollup/rollup-win32-x64-gnu': 4.59.1 - '@rollup/rollup-win32-x64-msvc': 4.59.1 + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -12210,7 +12134,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -12264,12 +12188,6 @@ snapshots: type-fest: 0.13.1 optional: true - seroval-plugins@1.5.1(seroval@1.5.1): - dependencies: - seroval: 1.5.1 - - seroval@1.5.1: {} - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -12279,34 +12197,38 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + + set-cookie-parser@3.1.0: {} + setprototypeof@1.2.0: {} - shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3): + shadcn@4.7.0(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@dotenvx/dotenvx': 1.57.0 - '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@dotenvx/dotenvx': 1.65.0 + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.1 + browserslist: 4.28.2 commander: 14.0.3 cosmiconfig: 9.0.1(typescript@5.9.3) dedent: 1.7.2 deepmerge: 4.3.1 - diff: 8.0.3 + diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 fs-extra: 11.3.4 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3) + msw: 2.14.3(@types/node@25.6.0)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 - postcss: 8.5.8 + postcss: 8.5.14 postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 @@ -12316,7 +12238,7 @@ snapshots: tsconfig-paths: 4.2.0 validate-npm-package-name: 7.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - '@types/node' @@ -12330,7 +12252,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -12354,7 +12276,7 @@ snapshots: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -12372,10 +12294,12 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - simple-git@3.33.0: + simple-git@3.36.0: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 + '@simple-git/args-pathspec': 1.0.3 + '@simple-git/argv-parser': 1.1.1 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12403,33 +12327,15 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - smart-buffer@4.2.0: {} + smart-buffer@4.2.0: + optional: true - smol-toml@1.6.0: {} + smol-toml@1.6.1: {} - socks-proxy-agent@8.0.5: + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 - - solid-js@1.9.11: - dependencies: - csstype: 3.2.3 - seroval: 1.5.1 - seroval-plugins: 1.5.1(seroval@1.5.1) - - sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) source-map-js@1.2.1: {} @@ -12453,10 +12359,6 @@ snapshots: cpu-features: 0.0.10 nan: 2.26.2 - ssri@12.0.0: - dependencies: - minipass: 7.1.3 - stackback@0.0.2: {} stat-mode@1.0.0: {} @@ -12465,10 +12367,12 @@ snapshots: statuses@2.0.2: {} - std-env@4.0.0: {} + std-env@4.1.0: {} stdin-discarder@0.2.2: {} + stdin-discarder@0.3.2: {} + strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} @@ -12479,19 +12383,13 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string-width@7.2.0: dependencies: emoji-regex: 10.6.0 get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 - string-width@8.2.0: + string-width@8.2.1: dependencies: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 @@ -12535,7 +12433,7 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylis@4.3.6: {} + stylis@4.4.0: {} sumchecker@3.0.1: dependencies: @@ -12551,9 +12449,9 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.2: {} + tailwindcss@4.2.4: {} - tapable@2.3.0: {} + tapable@2.3.3: {} tar-fs@2.1.4: dependencies: @@ -12570,7 +12468,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@7.5.12: + tar@7.5.14: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -12600,22 +12498,22 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.0.4: {} + tinyexec@1.1.2: {} - tinyglobby@0.2.15: + tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinypool@2.1.0: {} tinyrainbow@3.1.0: {} - tldts-core@7.0.27: {} + tldts-core@7.0.30: {} - tldts@7.0.27: + tldts@7.0.30: dependencies: - tldts-core: 7.0.27 + tldts-core: 7.0.30 tmp-promise@3.0.3: dependencies: @@ -12631,7 +12529,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.0.27 + tldts: 7.0.30 trim-lines@3.0.1: {} @@ -12664,10 +12562,12 @@ snapshots: tweetnacl@0.14.5: {} + tweetnacl@1.0.3: {} + type-fest@0.13.1: optional: true - type-fest@5.5.0: + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -12679,13 +12579,15 @@ snapshots: typescript@5.9.3: {} - ufo@1.6.3: {} + ufo@1.6.4: {} undici-types@5.26.5: {} undici-types@7.16.0: {} - undici-types@7.18.2: {} + undici-types@7.19.2: {} + + undici@6.25.0: {} unicorn-magic@0.3.0: {} @@ -12699,14 +12601,6 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unique-filename@4.0.0: - dependencies: - unique-slug: 5.0.0 - - unique-slug@5.0.0: - dependencies: - imurmurhash: 0.1.4 - unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -12748,9 +12642,9 @@ snapshots: until-async@3.0.2: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -12758,30 +12652,30 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): dependencies: detect-node-es: 1.1.0 - react: 19.2.4 + react: 19.2.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - use-sync-external-store@1.6.0(react@19.2.4): + use-sync-external-store@1.6.0(react@19.2.5): dependencies: - react: 19.2.4 + react: 19.2.5 utf8-byte-length@1.0.5: {} util-deprecate@1.0.2: {} - uuid@11.1.0: {} + uuid@11.1.1: {} validate-npm-package-name@7.0.2: {} @@ -12809,45 +12703,45 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.8 - rollup: 4.59.1 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.32.0 - yaml: 2.8.3 + yaml: 2.8.4 - vitest@4.1.1(@types/node@25.5.0)(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)): + vitest@4.1.5(@types/node@25.6.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): dependencies: - '@vitest/expect': 4.1.1 - '@vitest/mocker': 4.1.1(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.1 - '@vitest/runner': 4.1.1 - '@vitest/snapshot': 4.1.1 - '@vitest/spy': 4.1.1 - '@vitest/utils': 4.1.1 - es-module-lexer: 2.0.0 + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.0.0 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 transitivePeerDependencies: - msw @@ -12870,14 +12764,12 @@ snapshots: w3c-keyname@2.2.8: {} - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -12890,6 +12782,10 @@ snapshots: dependencies: isexe: 3.1.5 + which@6.0.1: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -12907,12 +12803,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -12930,6 +12820,8 @@ snapshots: xmlbuilder@15.1.1: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -12938,10 +12830,29 @@ snapshots: yallist@5.0.0: {} - yaml@2.8.3: {} + yaml@2.8.4: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -12959,22 +12870,24 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.3: {} + yocto-spinner@1.2.0: + dependencies: + yoctocolors: 2.1.2 yoctocolors@2.1.2: {} - zod-to-json-schema@3.25.1(zod@3.25.76): + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 zod@3.25.76: {} - zod@4.3.6: {} + zod@4.4.3: {} - zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + zustand@5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): optionalDependencies: '@types/react': 19.2.14 - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) zwitch@2.0.4: {} diff --git a/resources/build/entitlements.computer-use.mac.plist b/resources/build/entitlements.computer-use.mac.plist new file mode 100644 index 00000000000..6631ffa6f24 --- /dev/null +++ b/resources/build/entitlements.computer-use.mac.plist @@ -0,0 +1,6 @@ + + + + + + diff --git a/skills/computer-use/SKILL.md b/skills/computer-use/SKILL.md new file mode 100644 index 00000000000..e44e1b5d2ff --- /dev/null +++ b/skills/computer-use/SKILL.md @@ -0,0 +1,192 @@ +--- +name: computer-use +description: Use Orca's computer-use CLI to inspect and control local desktop apps through accessibility trees, screenshots, and safe UI actions. Use when an agent needs to list desktop apps, get an app state, read visible UI, click, type, press keys, scroll, drag, set values, or perform app accessibility actions. Triggers include "computer use", "orca computer", "list apps", "get app state", "read Spotify", "read Slack", "click app", "type text", "press key", "set value", "scroll app", "drag app", and desktop app interaction tasks. +--- + +# Computer Use + +Use this skill when the task should operate through Orca's desktop computer-use surface rather than native Codex computer tools, raw AppleScript, ad hoc screenshots, or direct app internals. + +## Preconditions + +- Prefer the public `orca computer ...` command. +- In this Orca worktree, use `./config/scripts/orca-dev computer ...` when testing the local dev runtime. +- Prefer `--json` for agent-driven calls. Screenshot image bytes are omitted from JSON and written to `screenshot.path` when present. +- Do not push, submit forms, send messages, buy items, delete data, or change account settings unless the user explicitly asked for that specific action. +- If an app contains sensitive content, read only what the user requested and avoid unnecessary screenshots or logs. + +Check runtime availability first: + +```bash +orca status --json +orca computer capabilities --json +``` + +For local development against this worktree: + +```bash +./config/scripts/orca-dev status --json +``` + +## Core Workflow + +Use a snapshot-act-snapshot loop: + +1. Discover apps: + +```bash +orca computer list-apps --json +``` + +2. Get a fresh state for the target app: + +```bash +orca computer get-app-state --app com.spotify.client --json +``` + +3. Choose an element from that state. + +4. Perform one action: + +```bash +orca computer click --app com.spotify.client --element-index 42 --json +``` + +5. Inspect the action result before deciding whether to act again. Actions return a fresh state: + +```bash +orca computer click --app com.spotify.client --element-index 42 --json +``` + +Element indexes are scoped to the current app state. They can go stale after navigation, focus changes, scrolling, window changes, or app re-rendering. Never carry indexes across unrelated steps without refreshing state. + +## App Selectors + +Prefer bundle IDs returned by `list-apps`: + +```bash +orca computer get-app-state --app com.microsoft.edgemac --json +orca computer get-app-state --app com.spotify.client --json +``` + +Names are acceptable when unambiguous: + +```bash +orca computer get-app-state --app Spotify --json +``` + +Use `pid:` only when bundle ID or name matching is ambiguous: + +```bash +orca computer get-app-state --app pid:12345 --json +``` + +## Commands + +```bash +orca computer permissions --json +orca computer capabilities --json +orca computer list-apps --json +orca computer list-windows --app --json +orca computer get-app-state --app --json +orca computer click --app --element-index --json +orca computer perform-secondary-action --app --element-index --action --json +orca computer set-value --app --element-index --value "text" --json +orca computer type-text --app --text "text" --json +orca computer press-key --app --key Return --json +orca computer hotkey --app --key CmdOrCtrl+A --json +orca computer paste-text --app --text "text" --json +orca computer scroll --app (--element-index | --x --y ) --direction down --json +orca computer drag --app --from-x 100 --from-y 100 --to-x 300 --to-y 300 --json +``` + +Use `--no-screenshot` only when pixels are not needed. Screenshots are often the only useful signal for Electron, WebView, or canvas-heavy apps with shallow accessibility trees. + +Coordinates are window-local. Use coordinates from the latest screenshot/state for the same target window. + +Use `--text-stdin` or `--value-stdin` for sensitive text so payloads do not land in shell history. +On Linux and Windows, action payloads still pass through a short-lived local operation file. + +```bash +printf '%s' "$TEXT" | orca computer set-value --app --element-index --value-stdin --json +``` + +## Choosing Actions + +Prefer semantic actions over raw keyboard input: + +- Use `set-value` for known editable fields. +- Use `click` for buttons, tabs, menu items, checkboxes, and other direct controls. +- Use `perform-secondary-action` only when the state lists a concrete action name and the user intent matches it. +- Use `type-text` after focusing a field and confirming the app has a focused text receiver. +- Use `press-key` for navigation keys, Return, Escape, shortcuts, or submitting a field after the state confirms the right target is active. + +Why: keyboard input is process-targeted on macOS, but it still depends on the target app having a valid focused receiver. `set-value` targets the accessibility element directly and is more reliable when supported. + +## Foreground And Background + +Some actions work while the app is in the background. Treat this as app-dependent: + +- `set-value` can work in the background when the app exposes a writable accessibility value. +- `click` and accessibility actions may work in the background for some native controls. +- `type-text` and `press-key` are targeted to the app process on macOS, but the app may ignore them unless it owns focus or already has an active text receiver. + +If an action returns success but the UI did not change, do not repeat the same action blindly. Run `get-app-state` again, inspect the screenshot/tree, then switch to a more semantic action or bring/focus the target if needed. + +## Screenshots + +`get-app-state` returns an accessibility tree and, by default, a screenshot. Use both: + +- Trust the tree for element indexes, names, roles, values, and actions. +- Trust the screenshot for visual confirmation, especially in Electron and WebView apps. +- If the tree is shallow, use screenshot evidence before deciding whether any action is safe. +- If screenshot capture fails or returns no image, the app may be hidden, minimized, off-screen, or have no visible window. + +Use restore only when appropriate for the task: + +```bash +orca computer get-app-state --app --restore-window --json +``` + +## App-Specific Notes + +### Browsers + +For Edge, Chrome, and similar browsers, prefer setting the address/search field directly: + +```bash +orca computer get-app-state --app com.microsoft.edgemac --json +orca computer set-value --app com.microsoft.edgemac --element-index --value "test123" --json +orca computer press-key --app com.microsoft.edgemac --key Return --json +orca computer get-app-state --app com.microsoft.edgemac --json +``` + +Do not assume raw typing went to the address bar. Confirm the field or page changed after pressing Return. + +### Spotify + +Spotify state can update asynchronously after playback or network-backed search. After a playback click, run `get-app-state` before clicking again. + +For search, prefer `set-value` on the search combobox, usually named like `What do you want to play?`. `type-text` may only work when Spotify owns focus and that field is already focused. + +### Slack + +Slack may expose a shallow accessibility tree while the screenshot contains the useful information. Reading visible Slack UI is acceptable when requested, but do not send messages or trigger workflows unless explicitly asked. + +## Error Handling + +- `app_not_found`: run `list-apps` and retry with the bundle ID. +- `element_not_found`: the index is stale; run `get-app-state` again. +- `action_failed`: inspect the element role/actions and try a more semantic action. +- Empty tree or no screenshot: the app may have no visible window, be minimized, or be blocked by permissions. +- Permission errors: the user needs to grant Accessibility or Screen Recording to `Orca Computer Use`. Run `orca computer permissions --json`, use the setup UI, then retry `orca computer get-app-state --app --json`. + +## Safety Checks + +Before acting, classify the action: + +- Safe: read state, list apps, inspect screenshot, focus a search box, scroll, open a harmless tab. +- Needs care: typing into a focused field, pressing Return, clicking a primary button. +- Requires explicit user permission: sending messages, posting, purchasing, deleting, submitting forms, changing settings, signing in, or exposing secrets. + +When uncertain, stop after `get-app-state` and report what is visible instead of acting. diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index c9d51287e07..b4831d58ae6 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -1,6 +1,18 @@ --- name: orca-cli -description: Use the Orca CLI to orchestrate worktrees, live terminals, and browser automation through a running Orca editor. Use when an agent needs to create, inspect, update, or remove Orca worktrees; inspect repo state known to Orca; read, send to, wait on, or stop Orca-managed terminals; or automate the built-in browser (navigate, snapshot, click, fill, screenshot). Coding agents should also keep the current worktree comment updated with the latest meaningful work-in-progress checkpoint whenever useful. Triggers include "use orca cli", "manage Orca worktrees", "read Orca terminal", "reply to Claude Code in Orca", "create a worktree in Orca", "update Orca worktree comment", "click on", "fill the form", "take a screenshot", "navigate to", "interact with the page", "snapshot the page", or any task where the agent should operate through Orca. +description: >- + Use the `orca` CLI to drive a running Orca editor — manage Orca worktrees; + create, read, and run shell commands in Orca-managed terminals; and automate + Orca's built-in browser (snapshot/click/fill/screenshot/tabs). Use this + instead of raw `git worktree`, ad hoc shell PTYs, or Playwright whenever the + task touches Orca state. Coding agents inside an Orca worktree should also use + it to keep the worktree comment fresh at meaningful checkpoints. Boundary with + `orchestration`: if the recipient of a terminal write is another AI agent + (Claude Code, Gemini, Codex, a worker), use `orchestration` — it is the only + correct way to send messages, nudges, replies, or task hand-offs to agents. + orca-cli writes are for non-agent terminals (shells, build/test commands); + reading or `wait`ing on any terminal — including agent terminals — stays in + orca-cli. --- # Orca CLI @@ -13,7 +25,7 @@ Use `orca` for: - worktree orchestration inside a running Orca app - updating the current worktree comment with meaningful progress checkpoints -- reading and replying to Orca-managed terminals +- reading Orca-managed terminals and sending input to non-agent terminals - stopping or waiting on Orca-managed terminals - accessing repos known to Orca Do not use `orca` when plain shell tools are simpler and Orca state does not matter. @@ -22,7 +34,7 @@ Examples: - creating one Orca worktree per GitHub issue - updating the current worktree comment after a significant checkpoint, such as reproducing a bug, validating a fix, or handing off for review -- finding the Claude Code terminal for a worktree and replying to it +- finding the Claude Code terminal for a worktree and reading its status - checking which Orca worktrees have live terminal activity ## Preconditions diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 5de16074078..a911c3519c8 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -1,6 +1,20 @@ --- name: orchestration -description: Use the Orca CLI to coordinate multiple coding agents via inter-agent messaging, task DAGs, dispatch with preamble injection, decision gates, and coordinator loops. Use when an agent needs to send or check inter-agent messages; create, dispatch, or track orchestration tasks; coordinate multi-agent workflows; or act as a coordinator dispatching work across terminals. Triggers include "orchestrate agents", "dispatch task", "send message to agent", "check inbox", "coordinate agents", "multi-agent", "create task DAG", "worker_done", "escalation", or any task involving inter-agent coordination through Orca. +description: >- + Use `orca orchestration` when coordinating multiple AI agents through Orca: + pass context, instructions, questions, findings, reviews, handoffs, or + structured results between agents; delegate subtasks; dispatch work; track task + DAGs; wait for worker_done/escalation; or run coordinator loops and decision + gates. This is the right skill for semantic agent-to-agent communication. + Boundary with `orca-cli`: do not use orchestration for ordinary terminal + control, shell commands, browser automation, worktree management, or + reading/waiting on terminals; use `orca-cli` for those. Triggers include "tell + the other agent", "ask ", "hand off to", "pass this to ", + "delegate to ", "have do X", "let know", "share with the + other AI", "coordinate agents", "what did find", "check orchestration + inbox", "reply to ", "dispatch task", "orchestrate agents", + "multi-agent", "task DAG", "worker_done", "escalation", "decision gate", and + "coordinator loop". --- # Orca Inter-Agent Orchestration diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts new file mode 100644 index 00000000000..868e66516e6 --- /dev/null +++ b/src/cli/args.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' + +import { parseArgs } from './args' + +describe('parseArgs', () => { + it('keeps an empty string as a flag value', () => { + const parsed = parseArgs(['computer', 'set-value', '--value', '', '--json']) + + expect(parsed.commandPath).toEqual(['computer', 'set-value']) + expect(parsed.flags.get('value')).toBe('') + expect(parsed.flags.get('json')).toBe(true) + }) +}) diff --git a/src/cli/args.ts b/src/cli/args.ts index f61cfb751a1..de0aff3c320 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -28,8 +28,9 @@ export function parseArgs(argv: string[]): ParsedArgs { } const flag = token.slice(2) + const hasNext = i + 1 < argv.length const next = argv[i + 1] - if (!next || next.startsWith('--')) { + if (!hasNext || next.startsWith('--')) { flags.set(flag, true) continue } @@ -61,10 +62,17 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { if (['open', 'status'].includes(commandPath[0])) { return false } - if (['repo', 'worktree', 'terminal'].includes(commandPath[0])) { + if (['repo', 'worktree', 'terminal', 'computer'].includes(commandPath[0])) { return false } - return !['tab list', 'tab create'].includes(joined) + return ![ + 'tab list', + 'tab create', + 'tab current', + 'tab profile list', + 'tab profile create', + 'tab profile delete' + ].includes(joined) } export function isCommandGroup(commandPath: string[]): boolean { @@ -83,7 +91,8 @@ export function isCommandGroup(commandPath: string[]): boolean { 'clipboard', 'dialog', 'storage', - 'orchestration' + 'orchestration', + 'computer' ].includes(commandPath[0])) || (commandPath.length === 2 && commandPath[0] === 'storage' && diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index c0f518b8ede..161368f7a0e 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */ import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -126,6 +127,395 @@ describe('orca cli browser page targeting', () => { worktree: `path:${path.resolve('/tmp/repo/feature')}` }) }) + + it('passes focus: true through to browser.tabSwitch when --focus is set', async () => { + queueFixtures(callMock, okFixture('req_switch', { switched: 1, browserPageId: 'page-1' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'switch', '--page', 'page-1', '--focus', '--json'], '/tmp/repo/feature/src') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabSwitch', { + index: undefined, + page: 'page-1', + focus: true + }) + }) + + it('omits focus from the payload when --focus is absent', async () => { + queueFixtures(callMock, okFixture('req_switch', { switched: 1, browserPageId: 'page-1' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'switch', '--page', 'page-1', '--json'], '/tmp/repo/feature/src') + + expect(callMock).toHaveBeenCalledTimes(1) + const call = callMock.mock.calls[0] + expect(call[0]).toBe('browser.tabSwitch') + expect(call[1]).not.toHaveProperty('focus') + }) + + it('passes explicit profile ids to tab create', async () => { + queueFixtures(callMock, okFixture('req_create', { browserPageId: 'page-3' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'tab', + 'create', + '--url', + 'https://example.com', + '--profile', + 'work', + '--worktree', + 'all', + '--json' + ], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith( + 'browser.tabCreate', + { + url: 'https://example.com', + worktree: undefined, + profileId: 'work' + }, + { timeoutMs: 60_000 } + ) + }) + + it('passes tab profile updates through by page id', async () => { + queueFixtures( + callMock, + okFixture('req_set_profile', { + browserPageId: 'page-2', + profileId: 'work', + profileLabel: 'Work' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'set', '--page', 'page-2', '--profile', 'work', '--json'], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabSetProfile', { + page: 'page-2', + profileId: 'work' + }) + }) + + it('shows tab profile labels in text mode when requested', async () => { + queueFixtures( + callMock, + okFixture('req_list', { + tabs: [ + { + browserPageId: 'page-1', + index: 0, + url: 'https://example.com', + title: 'Example', + active: true, + profileId: 'default', + profileLabel: 'Default' + }, + { + browserPageId: 'page-2', + index: 1, + url: 'https://mail.example.com', + title: 'Mail', + active: false, + profileId: 'work', + profileLabel: 'Work' + } + ] + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'list', '--show-profile', '--worktree', 'all'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabList', { worktree: undefined }) + expect(logSpy).toHaveBeenCalledWith( + '* [0] page-1 Example — https://example.com [Default]\n' + + ' [1] page-2 Mail — https://mail.example.com [Work]' + ) + }) + + it('shows a single tab by page id', async () => { + queueFixtures( + callMock, + okFixture('req_tab_show', { + tab: { + browserPageId: 'page-1', + index: 0, + url: 'https://example.com', + title: 'Example', + active: true, + worktreeId: 'repo::/tmp/repo/feature', + profileId: 'work', + profileLabel: 'Work' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'show', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabShow', { page: 'page-1' }) + }) + + it('resolves the current tab in a worktree', async () => { + queueFixtures( + callMock, + okFixture('req_tab_current', { + tab: { + browserPageId: 'page-2', + index: 1, + url: 'https://mail.example.com', + title: 'Mail', + active: true, + worktreeId: 'repo::/tmp/repo/feature', + profileId: 'default', + profileLabel: 'Default' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'current', '--worktree', 'all', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined }) + }) +}) + +describe('orca cli browser tab profiles', () => { + beforeEach(() => { + callMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows the profile bound to a tab', async () => { + queueFixtures( + callMock, + okFixture('req_profile_show', { + browserPageId: 'page-2', + worktreeId: 'repo::/tmp/repo/feature', + profileId: 'work', + profileLabel: 'Work' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'show', '--page', 'page-2', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabProfileShow', { page: 'page-2' }) + }) + + it('switches a tab back to the default profile', async () => { + queueFixtures( + callMock, + okFixture('req_profile_default', { + browserPageId: 'page-2', + profileId: 'default', + profileLabel: 'Default' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'use-default', '--page', 'page-2', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabSetProfile', { + page: 'page-2', + profileId: 'default' + }) + }) + + it('clones a tab into a different profile', async () => { + queueFixtures( + callMock, + okFixture('req_profile_clone', { + browserPageId: 'page-9', + sourceBrowserPageId: 'page-2', + profileId: 'work', + profileLabel: 'Work' + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'clone', '--page', 'page-2', '--profile', 'work', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.tabProfileClone', { + page: 'page-2', + profileId: 'work' + }) + }) +}) + +describe('orca cli browser tab profiles', () => { + beforeEach(() => { + callMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('lists browser tab profiles', async () => { + queueFixtures( + callMock, + okFixture('req_profiles', { + profiles: [ + { id: 'default', scope: 'default', label: 'Default', partition: 'persist:orca-browser' }, + { + id: 'work', + scope: 'isolated', + label: 'Work', + partition: 'persist:orca-browser-session-work' + } + ] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'profile', 'list', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.profileList') + }) + + it('reports an empty browser tab profile list with a friendly message', async () => { + queueFixtures(callMock, okFixture('req_profiles', { profiles: [] })) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'profile', 'list'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.profileList') + expect(logSpy).toHaveBeenCalledWith('No browser profiles found.') + }) + + it('creates isolated browser tab profiles by default', async () => { + queueFixtures( + callMock, + okFixture('req_profile_create', { + profile: { + id: 'work', + scope: 'isolated', + label: 'Work', + partition: 'persist:orca-browser-session-work' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'create', '--label', 'Work', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.profileCreate', { + label: 'Work', + scope: 'isolated' + }) + }) + + it('forwards --scope imported through to the runtime', async () => { + queueFixtures( + callMock, + okFixture('req_profile_create', { + profile: { + id: 'imp', + scope: 'imported', + label: 'From Chrome', + partition: 'persist:orca-browser-session-imp' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'create', '--label', 'From Chrome', '--scope', 'imported', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledWith('browser.profileCreate', { + label: 'From Chrome', + scope: 'imported' + }) + }) + + it('rejects unknown --scope values instead of silently defaulting to isolated', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'create', '--label', 'Work', '--scope', 'isloated'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith('--scope must be "isolated" or "imported"') + }) + + it('surfaces a runtime error if the registry refuses to create a profile', async () => { + queueFixtures(callMock, okFixture('req_profile_create', { profile: null })) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['tab', 'profile', 'create', '--label', 'Bogus'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to create browser profile (label=Bogus, scope=isolated)' + ) + }) + + it('deletes browser tab profiles by id', async () => { + queueFixtures(callMock, okFixture('req_profile_delete', { deleted: true, profileId: 'work' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'delete', '--profile', 'work', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.profileDelete', { profileId: 'work' }) + }) + + it('reports a not-deleted profile in text mode without throwing', async () => { + queueFixtures( + callMock, + okFixture('req_profile_delete', { deleted: false, profileId: 'default' }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'profile', 'delete', '--profile', 'default'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledWith('browser.profileDelete', { profileId: 'default' }) + expect(logSpy).toHaveBeenCalledWith('Profile default was not deleted') + }) }) describe('orca cli browser waits and viewport flags', () => { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 5b245c99230..8ea7abf7289 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -7,11 +7,13 @@ import { TERMINAL_HANDLERS } from './handlers/terminal' import { BROWSER_NAV_HANDLERS } from './handlers/browser-nav' import { BROWSER_INTERACT_HANDLERS } from './handlers/browser-interact' import { BROWSER_TAB_HANDLERS } from './handlers/browser-tab' +import { BROWSER_PROFILE_HANDLERS } from './handlers/browser-profile' import { BROWSER_COOKIE_HANDLERS } from './handlers/browser-cookie' import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture' import { BROWSER_ENV_HANDLERS } from './handlers/browser-env' import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' +import { COMPUTER_HANDLERS } from './handlers/computer' export type HandlerContext = { flags: Map @@ -32,11 +34,13 @@ function buildHandlers(): Map { BROWSER_NAV_HANDLERS, BROWSER_INTERACT_HANDLERS, BROWSER_TAB_HANDLERS, + BROWSER_PROFILE_HANDLERS, BROWSER_COOKIE_HANDLERS, BROWSER_CAPTURE_HANDLERS, BROWSER_ENV_HANDLERS, BROWSER_STORAGE_HANDLERS, - ORCHESTRATION_HANDLERS + ORCHESTRATION_HANDLERS, + COMPUTER_HANDLERS ] for (const group of groups) { for (const [key, handler] of Object.entries(group)) { diff --git a/src/cli/flags.test.ts b/src/cli/flags.test.ts new file mode 100644 index 00000000000..020d7827a20 --- /dev/null +++ b/src/cli/flags.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' + +import { getRequiredStringFlagAllowingEmpty } from './flags' + +describe('CLI flags', () => { + it('allows required string flags to be empty when the command opts in', () => { + const flags = new Map([['value', '']]) + + expect(getRequiredStringFlagAllowingEmpty(flags, 'value')).toBe('') + }) +}) diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 3c3383dd68d..ed8b90f9a4b 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -8,6 +8,17 @@ export function getRequiredStringFlag(flags: Map, name throw new RuntimeClientError('invalid_argument', `Missing required --${name}`) } +export function getRequiredStringFlagAllowingEmpty( + flags: Map, + name: string +): string { + const value = flags.get(name) + if (typeof value === 'string') { + return value + } + throw new RuntimeClientError('invalid_argument', `Missing required --${name}`) +} + export function getOptionalStringFlag( flags: Map, name: string diff --git a/src/cli/format.ts b/src/cli/format.ts index b1d1ac43269..93bc9d6fb5c 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,7 +1,21 @@ +/* eslint-disable max-lines -- Why: CLI result formatters are centralized so handlers can stay thin RPC glue. */ +import { chmodSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import type { + BrowserProfileListResult, + BrowserTabCurrentResult, BrowserScreenshotResult, BrowserSnapshotResult, BrowserTabListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabShowResult, + ComputerActionResult, + ComputerActionVerification, + ComputerListAppsResult, + ComputerListWindowsResult, + ComputerSnapshotResult, CliStatusResult, RuntimeRepoList, RuntimeRepoSearchRefs, @@ -28,7 +42,7 @@ export function printResult( formatter: (value: TResult) => string ): void { if (json) { - console.log(JSON.stringify(response, null, 2)) + console.log(JSON.stringify(prepareCliJsonResult(response), null, 2)) return } console.log(formatter(response.result)) @@ -138,7 +152,8 @@ export function formatTerminalRename(result: { rename: RuntimeTerminalRename }): export function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string { const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : '' - return `Created terminal ${result.terminal.handle}${titleNote}` + const surfaceNote = result.terminal.surface ? ` [${result.terminal.surface}]` : '' + return `Created terminal ${result.terminal.handle}${titleNote}${surfaceNote}` } export function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string { @@ -237,13 +252,249 @@ export function formatScreenshot(result: BrowserScreenshotResult): string { } export function formatTabList(result: BrowserTabListResult): string { + return formatTabListWithProfiles(result, false) +} + +export function formatTabListWithProfiles( + result: BrowserTabListResult, + showProfile: boolean +): string { if (result.tabs.length === 0) { return 'No browser tabs open.' } return result.tabs .map((t) => { const marker = t.active ? '* ' : ' ' - return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}` + const profile = showProfile ? ` [${t.profileLabel ?? t.profileId ?? 'Unknown'}]` : '' + return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}${profile}` }) .join('\n') } + +export function formatBrowserProfileList(result: BrowserProfileListResult): string { + if (result.profiles.length === 0) { + return 'No browser profiles found.' + } + return result.profiles + .map((profile) => { + const marker = profile.scope === 'default' ? '* ' : ' ' + const source = profile.source?.browserFamily ?? 'none' + return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}` + }) + .join('\n') +} + +export function formatTabShow(result: BrowserTabShowResult | BrowserTabCurrentResult): string { + const tab = result.tab + return [ + `page: ${tab.browserPageId}`, + `title: ${tab.title}`, + `url: ${tab.url}`, + `active: ${tab.active}`, + `worktree: ${tab.worktreeId ?? 'unknown'}`, + `profile: ${tab.profileLabel ?? tab.profileId ?? 'unknown'}` + ].join('\n') +} + +export function formatTabProfileShow(result: BrowserTabProfileShowResult): string { + return [ + `page: ${result.browserPageId}`, + `worktree: ${result.worktreeId ?? 'unknown'}`, + `profileId: ${result.profileId ?? 'default'}`, + `profile: ${result.profileLabel ?? result.profileId ?? 'default'}` + ].join('\n') +} + +export function formatTabProfileClone(result: BrowserTabProfileCloneResult): string { + return `Cloned ${result.sourceBrowserPageId} to ${result.browserPageId} (${result.profileLabel ?? result.profileId ?? 'default'})` +} + +export function formatGetAppState(result: ComputerSnapshotResult): string { + const app = result.snapshot.app + const bundle = app.bundleId ? `, ${app.bundleId}` : '' + const focused = + result.snapshot.focusedElementId === null ? 'none' : `#${result.snapshot.focusedElementId}` + const windowId = + result.snapshot.window.id === null || result.snapshot.window.id === undefined + ? '' + : ` id:${result.snapshot.window.id}` + const origin = + result.snapshot.window.x === null || + result.snapshot.window.x === undefined || + result.snapshot.window.y === null || + result.snapshot.window.y === undefined + ? '' + : ` @ ${result.snapshot.window.x},${result.snapshot.window.y}` + const truncation = result.snapshot.truncation?.truncated + ? ` Truncated: yes (max nodes ${result.snapshot.truncation.maxNodes ?? 'unknown'}, max depth ${result.snapshot.truncation.maxDepth ?? 'unknown'})` + : ' Truncated: no' + return [ + `${app.name} (pid ${app.pid}${bundle})`, + ` Window:${windowId} "${result.snapshot.window.title}" (${result.snapshot.window.width}x${result.snapshot.window.height}${origin})`, + ` Elements: ${result.snapshot.elementCount} Focused: ${focused} Coordinates: ${result.snapshot.coordinateSpace}`, + truncation, + ` ${formatComputerScreenshotStatus(result)}`, + '', + result.snapshot.treeText + ].join('\n') +} + +function prepareCliJsonResult( + response: RuntimeRpcSuccess +): RuntimeRpcSuccess { + const record = response as RuntimeRpcSuccess & { + result?: { screenshot?: { data?: unknown; format?: unknown; path?: unknown } | null } + } + const screenshot = record.result?.screenshot + if (!screenshot || typeof screenshot.data !== 'string' || screenshot.data.length === 0) { + return response + } + const extension = screenshot.format === 'png' ? 'png' : 'img' + const outputDir = computerScreenshotTempDir() + cleanupComputerScreenshots(outputDir) + const outputPath = join(outputDir, `${safeCliFileStem(response.id)}-screenshot.${extension}`) + writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'), { mode: 0o600 }) + const expiresAt = new Date(Date.now() + COMPUTER_SCREENSHOT_TTL_MS).toISOString() + return { + ...response, + result: { + ...record.result, + screenshot: { + ...screenshot, + data: undefined, + path: outputPath, + dataOmitted: true, + expiresAt + } + } + } as RuntimeRpcSuccess +} + +const COMPUTER_SCREENSHOT_TTL_MS = 24 * 60 * 60 * 1000 + +function computerScreenshotTempDir(): string { + const outputDir = join(tmpdir(), 'orca-computer-use') + mkdirSync(outputDir, { recursive: true, mode: 0o700 }) + const stat = lstatSync(outputDir) + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Unsafe computer screenshot temp path: ${outputDir}`) + } + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new Error(`Computer screenshot temp path is not owned by the current user: ${outputDir}`) + } + chmodSync(outputDir, 0o700) + return outputDir +} + +function cleanupComputerScreenshots(outputDir: string): void { + const cutoff = Date.now() - COMPUTER_SCREENSHOT_TTL_MS + for (const entry of readdirSync(outputDir)) { + if (!entry.endsWith('-screenshot.png') && !entry.endsWith('-screenshot.img')) { + continue + } + const path = join(outputDir, entry) + try { + if (statSync(path).mtimeMs < cutoff) { + rmSync(path, { force: true }) + } + } catch { + // Best-effort cleanup only; formatting should not fail because a temp file raced. + } + } +} + +function safeCliFileStem(value: string): string { + return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_') +} + +export function formatListApps(result: ComputerListAppsResult): string { + if (result.apps.length === 0) { + return 'No apps found.' + } + return result.apps + .map((app) => { + const bundle = app.bundleId ? ` ${app.bundleId}` : '' + return `${app.name} pid:${app.pid}${bundle}` + }) + .join('\n') +} + +export function formatListWindows(result: ComputerListWindowsResult): string { + if (result.windows.length === 0) { + return `No windows found for ${result.app.name}.` + } + return result.windows + .map((window) => { + const id = window.id === null || window.id === undefined ? 'none' : String(window.id) + const origin = + window.x === null || window.x === undefined || window.y === null || window.y === undefined + ? '' + : ` @ ${window.x},${window.y}` + const screen = + window.screenIndex === null || window.screenIndex === undefined + ? '' + : ` screen:${window.screenIndex}` + const state = [ + window.isMinimized ? 'minimized' : null, + window.isOffscreen ? 'offscreen' : null + ].filter(Boolean) + const stateText = state.length > 0 ? ` ${state.join(',')}` : '' + return `[${window.index}] id:${id} "${window.title}" (${window.width}x${window.height}${origin})${screen}${stateText}` + }) + .join('\n') +} + +export function formatComputerAction(verb: string, result: ComputerActionResult): string { + const path = result.action?.path ? ` via ${result.action.path}` : '' + const verification = formatActionVerification(result.action?.verification) + const app = shellQuote(result.snapshot.app.bundleId ?? result.snapshot.app.name) + const windowId = + result.snapshot.window.id === null || result.snapshot.window.id === undefined + ? '' + : ` --window-id ${result.snapshot.window.id}` + return `${formatActionVerb(verb)} completed${path}${verification}; ${result.snapshot.elementCount} elements in current window. Use \`orca computer get-app-state --app ${app}${windowId}\` to inspect.` +} + +function formatActionVerification(verification: ComputerActionVerification | undefined): string { + if (!verification) { + return '' + } + if (verification.state === 'verified') { + return `, verified ${verification.property}` + } + return `, unverified (${verification.reason.replaceAll('_', ' ')})` +} + +function formatComputerScreenshotStatus(result: ComputerSnapshotResult): string { + if (result.screenshotStatus.state === 'captured' && result.screenshot) { + const bytes = result.screenshot.data + ? `${Math.round(result.screenshot.data.length * 0.75)} bytes` + : `saved to ${result.screenshot.path ?? 'temporary file'}` + const engine = result.screenshotStatus.metadata?.engine + const detail = engine + ? `${result.screenshot.format}, ${bytes}, ${engine}` + : `${result.screenshot.format}, ${bytes}` + return `Screenshot captured (${detail})` + } + if (result.screenshotStatus.state === 'skipped') { + return 'Screenshot skipped (--no-screenshot)' + } + if (result.screenshotStatus.state === 'failed') { + return `Screenshot failed (${result.screenshotStatus.code}): ${result.screenshotStatus.message}` + } + return 'Screenshot was not captured' +} + +function shellQuote(value: string): string { + if (/^[a-zA-Z0-9._:/@-]+$/.test(value)) { + return value + } + return `'${value.replaceAll("'", "'\\''")}'` +} + +function formatActionVerb(verb: string): string { + return verb + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} diff --git a/src/cli/handlers/browser-profile.ts b/src/cli/handlers/browser-profile.ts new file mode 100644 index 00000000000..4dc8ad731b3 --- /dev/null +++ b/src/cli/handlers/browser-profile.ts @@ -0,0 +1,106 @@ +import type { + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabSetProfileResult +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { + formatBrowserProfileList, + formatTabProfileClone, + formatTabProfileShow, + printResult +} from '../format' +import { RuntimeClientError } from '../runtime-client' +import { getBrowserCommandTarget } from '../selectors' + +function parseScopeFlag(flags: Map): 'isolated' | 'imported' { + const raw = getOptionalStringFlag(flags, 'scope') + if (raw === undefined || raw === 'isolated') { + return 'isolated' + } + if (raw === 'imported') { + return 'imported' + } + throw new RuntimeClientError('invalid_argument', '--scope must be "isolated" or "imported"') +} + +export const BROWSER_PROFILE_HANDLERS: Record = { + 'tab profile list': async ({ client, json }) => { + const result = await client.call('browser.profileList') + printResult(result, json, formatBrowserProfileList) + }, + 'tab profile create': async ({ flags, client, json }) => { + const label = getRequiredStringFlag(flags, 'label') + const scope = parseScopeFlag(flags) + const result = await client.call('browser.profileCreate', { + label, + scope + }) + if (result.result.profile === null) { + // Why: registry refuses non-isolated/imported scopes; we already validated + // the scope client-side, so a null here means a server-side rejection we + // shouldn't silently report as success. + throw new RuntimeClientError( + 'runtime_error', + `Failed to create browser profile (label=${label}, scope=${scope})` + ) + } + printResult( + result, + json, + (value) => + `Created profile ${value.profile?.id ?? 'unknown'} (${value.profile?.label ?? label})` + ) + }, + 'tab profile delete': async ({ flags, client, json }) => { + const profileId = getRequiredStringFlag(flags, 'profile') + const result = await client.call('browser.profileDelete', { + profileId + }) + printResult(result, json, (value) => + value.deleted + ? `Deleted profile ${value.profileId}` + : `Profile ${value.profileId} was not deleted` + ) + }, + 'tab profile set': async ({ flags, client, cwd, json }) => { + const profileId = getRequiredStringFlag(flags, 'profile') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabSetProfile', { + ...target, + profileId + }) + printResult( + result, + json, + (value) => + `Switched ${value.browserPageId} to ${value.profileLabel ?? value.profileId ?? 'default'}` + ) + }, + 'tab profile show': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabProfileShow', target) + printResult(result, json, formatTabProfileShow) + }, + 'tab profile use-default': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabSetProfile', { + ...target, + profileId: 'default' + }) + printResult(result, json, (value) => `Switched ${value.browserPageId} to Default`) + }, + 'tab profile clone': async ({ flags, client, cwd, json }) => { + const profileId = getRequiredStringFlag(flags, 'profile') + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabProfileClone', { + ...target, + profileId + }) + printResult(result, json, formatTabProfileClone) + } +} diff --git a/src/cli/handlers/browser-tab.ts b/src/cli/handlers/browser-tab.ts index e842cd33c14..3e975bb360b 100644 --- a/src/cli/handlers/browser-tab.ts +++ b/src/cli/handlers/browser-tab.ts @@ -1,6 +1,11 @@ -import type { BrowserTabListResult, BrowserTabSwitchResult } from '../../shared/runtime-types' +import type { + BrowserTabCurrentResult, + BrowserTabListResult, + BrowserTabShowResult, + BrowserTabSwitchResult +} from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' -import { formatTabList, printResult } from '../format' +import { formatTabList, formatTabListWithProfiles, formatTabShow, printResult } from '../format' import { getOptionalNonNegativeIntegerFlag, getOptionalStringFlag, @@ -13,7 +18,20 @@ export const BROWSER_TAB_HANDLERS: Record = { 'tab list': async ({ flags, client, cwd, json }) => { const worktree = await getBrowserWorktreeSelector(flags, cwd, client) const result = await client.call('browser.tabList', { worktree }) - printResult(result, json, formatTabList) + const showProfile = flags.has('show-profile') + printResult(result, json, (value) => + showProfile ? formatTabListWithProfiles(value, true) : formatTabList(value) + ) + }, + 'tab show': async ({ flags, client, cwd, json }) => { + const target = await getBrowserCommandTarget(flags, cwd, client) + const result = await client.call('browser.tabShow', target) + printResult(result, json, formatTabShow) + }, + 'tab current': async ({ flags, client, cwd, json }) => { + const worktree = await getBrowserWorktreeSelector(flags, cwd, client) + const result = await client.call('browser.tabCurrent', { worktree }) + printResult(result, json, formatTabShow) }, 'tab switch': async ({ flags, client, cwd, json }) => { const index = getOptionalNonNegativeIntegerFlag(flags, 'index') @@ -25,19 +43,26 @@ export const BROWSER_TAB_HANDLERS: Record = { // targeted tab switches should match the rest of the --page command model: // global by default, with --worktree only acting as explicit validation. const target = await getBrowserCommandTarget(flags, cwd, client) + // Why: --focus is an opt-in side effect. The renderer's handler is + // worktree-scoped: it surfaces the browser pane only when the user is + // already on the targeted worktree, otherwise it pre-stages silently. + // Spread conditionally so the RPC payload stays shape-identical to the + // pre-flag form when --focus is absent. const result = await client.call('browser.tabSwitch', { index, page, + ...(flags.has('focus') ? { focus: true } : {}), ...target }) printResult(result, json, (v) => `Switched to tab ${v.switched} (${v.browserPageId})`) }, 'tab create': async ({ flags, client, cwd, json }) => { const url = getOptionalStringFlag(flags, 'url') + const profileId = getOptionalStringFlag(flags, 'profile') const worktree = await getBrowserWorktreeSelector(flags, cwd, client) const result = await client.call<{ browserPageId: string }>( 'browser.tabCreate', - { url, worktree }, + { url, worktree, profileId }, { timeoutMs: 60_000 } ) printResult(result, json, (v) => `Created tab ${v.browserPageId}`) diff --git a/src/cli/handlers/computer.test.ts b/src/cli/handlers/computer.test.ts new file mode 100644 index 00000000000..d7e8d07ed9b --- /dev/null +++ b/src/cli/handlers/computer.test.ts @@ -0,0 +1,435 @@ +/* eslint-disable max-lines -- Why: computer CLI coverage shares one mocked runtime setup across command contracts. */ +import path from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('../runtime-client', () => { + class RuntimeClient { + call = callMock + getCliStatus = vi.fn() + openOrca = vi.fn() + } + + class RuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.code = code + } + } + + class RuntimeRpcFailureError extends RuntimeClientError { + readonly response: unknown + + constructor(response: unknown) { + super('runtime_error', 'runtime_error') + this.response = response + } + } + + return { + RuntimeClient, + RuntimeClientError, + RuntimeRpcFailureError + } +}) + +import { main } from '../index' +import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from '../test-fixtures' + +describe('orca computer CLI handlers', () => { + beforeEach(() => { + vi.restoreAllMocks() + callMock.mockReset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('prints group help with all computer subcommands', async () => { + await main(['computer', '--help'], '/tmp/repo') + + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain('get-app-state') + expect(output).toContain('hotkey') + expect(output).toContain('capabilities') + expect(output).toContain('list-apps') + expect(output).toContain('list-windows') + expect(output).toContain('paste-text') + expect(output).toContain('permissions') + expect(output).toContain('set-value') + }) + + it('passes list-apps through with a resolved worktree', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]), + okFixture('req_apps', { apps: [] }) + ) + + await main(['computer', 'list-apps', '--json'], '/tmp/repo/src') + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listApps', { + worktree: `path:${path.resolve('/tmp/repo')}` + }) + }) + + it('prints provider capabilities without resolving a worktree', async () => { + queueFixtures( + callMock, + okFixture('req_capabilities', { + platform: 'darwin', + provider: 'orca-computer-use-macos', + providerVersion: '1.0.0', + protocolVersion: 1, + supports: { + apps: { list: true, bundleIds: true, pids: true }, + windows: { list: true, targetById: true, targetByIndex: true }, + observation: { screenshot: true, elementFrames: true, annotatedScreenshot: false }, + actions: { click: true, setValue: true, pasteText: true }, + surfaces: {} + } + }) + ) + + await main(['computer', 'capabilities'], '/tmp/repo/src') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('computer.capabilities', {}) + expect(vi.mocked(console.log).mock.calls[0][0]).toContain('orca-computer-use-macos') + }) + + it('opens computer permission setup without resolving a worktree', async () => { + queueFixtures( + callMock, + okFixture('req_permissions', { + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + openedSettings: false, + launchedHelper: true + }) + ) + + await main(['computer', 'permissions'], '/tmp/repo/src') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('computer.permissions', {}) + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain('Opened Orca Computer Use permission setup') + expect(output).toContain('/Applications/Orca Computer Use.app') + }) + + it('passes get-app-state target and observe flags', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]), + okFixture('req_state', sampleSnapshot()) + ) + + await main( + [ + 'computer', + 'get-app-state', + '--app', + 'Finder', + '--no-screenshot', + '--restore-window', + '--json' + ], + '/tmp/repo/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(2, 'computer.getAppState', { + app: 'Finder', + worktree: `path:${path.resolve('/tmp/repo')}`, + noScreenshot: true, + restoreWindow: true + }) + }) + + it('passes list-windows target and formats window IDs', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]), + okFixture('req_windows', { + app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }, + windows: [ + { + app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }, + index: 0, + id: 42, + title: 'Recents', + x: 10, + y: 20, + width: 800, + height: 600, + isMinimized: false, + isOffscreen: false, + screenIndex: 0 + } + ] + }) + ) + + await main(['computer', 'list-windows', '--app', 'Finder'], '/tmp/repo/src') + + expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listWindows', { + app: 'Finder', + worktree: `path:${path.resolve('/tmp/repo')}` + }) + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain('[0] id:42 "Recents"') + }) + + it('does not resolve worktree when --session is explicit', async () => { + queueFixtures(callMock, okFixture('req_click', sampleSnapshot())) + + await main( + [ + 'computer', + 'click', + '--session', + 'manual', + '--app', + 'Finder', + '--element-index', + '3', + '--json' + ], + '/tmp/repo/src' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('computer.click', { + session: 'manual', + app: 'Finder', + elementIndex: 3, + x: undefined, + y: undefined, + clickCount: undefined, + mouseButton: undefined, + noScreenshot: undefined + }) + }) + + it('maps action command flags to RPC payloads', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'feature')]), + okFixture('req_drag', sampleSnapshot()) + ) + + await main( + [ + 'computer', + 'drag', + '--app', + 'Finder', + '--from-x', + '1', + '--from-y', + '2', + '--to-x', + '3', + '--to-y', + '4', + '--json' + ], + '/tmp/repo/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(2, 'computer.drag', { + app: 'Finder', + worktree: `path:${path.resolve('/tmp/repo')}`, + fromElementIndex: undefined, + toElementIndex: undefined, + fromX: 1, + fromY: 2, + toX: 3, + toY: 4, + noScreenshot: undefined + }) + }) + + it('maps coordinate scroll flags to RPC payloads', async () => { + queueFixtures(callMock, okFixture('req_scroll', sampleSnapshot())) + + await main( + [ + 'computer', + 'scroll', + '--session', + 'manual', + '--app', + 'Finder', + '--x', + '10', + '--y', + '20', + '--direction', + 'down', + '--pages', + '0.5', + '--json' + ], + '/tmp/repo/src' + ) + + expect(callMock).toHaveBeenCalledWith('computer.scroll', { + session: 'manual', + app: 'Finder', + elementIndex: undefined, + x: 10, + y: 20, + direction: 'down', + pages: 0.5, + noScreenshot: undefined + }) + }) + + it('maps hotkey and paste-text command flags to RPC payloads', async () => { + queueFixtures(callMock, okFixture('req_hotkey', sampleSnapshot())) + await main( + [ + 'computer', + 'hotkey', + '--session', + 'manual', + '--app', + 'Finder', + '--key', + 'CmdOrCtrl+L', + '--no-screenshot', + '--json' + ], + '/tmp/repo/src' + ) + + expect(callMock).toHaveBeenCalledWith('computer.hotkey', { + session: 'manual', + app: 'Finder', + key: 'CmdOrCtrl+L', + noScreenshot: true + }) + + callMock.mockReset() + vi.mocked(console.log).mockClear() + queueFixtures(callMock, okFixture('req_paste', sampleSnapshot())) + await main( + [ + 'computer', + 'paste-text', + '--session', + 'manual', + '--app', + 'Finder', + '--text', + 'hello world', + '--json' + ], + '/tmp/repo/src' + ) + + expect(callMock).toHaveBeenCalledWith('computer.pasteText', { + session: 'manual', + app: 'Finder', + text: 'hello world', + noScreenshot: undefined + }) + }) + + it('formats get-app-state without printing screenshot bytes in pretty mode', async () => { + queueFixtures(callMock, okFixture('req_state', sampleSnapshot())) + + await main(['computer', 'get-app-state', '--session', 'manual', '--app', 'Finder'], '/tmp/repo') + + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain('Finder (pid 100, com.apple.finder)') + expect(output).toContain('App=com.apple.finder') + expect(output).not.toContain('base64-data') + }) + + it('omits screenshot bytes from JSON output and writes a screenshot path', async () => { + queueFixtures(callMock, okFixture('req_state', sampleSnapshot())) + + await main( + ['computer', 'get-app-state', '--session', 'manual', '--app', 'Finder', '--json'], + '/tmp/repo' + ) + + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).not.toContain('base64-data') + const parsed = JSON.parse(output) + expect(parsed.result.screenshot).toMatchObject({ + dataOmitted: true, + format: 'png', + expiresAt: expect.any(String), + path: expect.stringContaining('orca-computer-use/req_state-screenshot.png') + }) + }) + + it('shows coordinate space and truncation in pretty state output', async () => { + queueFixtures(callMock, okFixture('req_state', sampleSnapshot())) + + await main(['computer', 'get-app-state', '--session', 'manual', '--app', 'Finder'], '/tmp/repo') + + const output = vi.mocked(console.log).mock.calls[0][0] + expect(output).toContain('Coordinates: window') + expect(output).toContain('Truncated: no') + }) + + it('passes get-app-state window target flags through', async () => { + queueFixtures(callMock, okFixture('req_snapshot', sampleSnapshot())) + + await main( + [ + 'computer', + 'get-app-state', + '--session', + 'manual', + '--app', + 'Finder', + '--window-id', + '42', + '--window-index', + '0', + '--no-screenshot', + '--json' + ], + '/tmp/repo/src' + ) + + expect(callMock).toHaveBeenCalledWith('computer.getAppState', { + session: 'manual', + app: 'Finder', + noScreenshot: true, + restoreWindow: undefined, + windowId: 42, + windowIndex: 0 + }) + }) +}) + +function sampleSnapshot() { + return { + snapshot: { + id: 'snap-test', + app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }, + window: { title: 'Finder', width: 800, height: 600 }, + coordinateSpace: 'window', + truncation: { truncated: false, maxNodes: 1200, maxDepth: 64, maxDepthReached: false }, + treeText: 'App=com.apple.finder (pid 100)\n0 standard window Finder', + elementCount: 1, + focusedElementId: 0 + }, + screenshot: { + data: 'base64-data', + format: 'png', + width: 800, + height: 600, + scale: 1 + }, + screenshotStatus: { state: 'captured' } + } +} diff --git a/src/cli/handlers/computer.ts b/src/cli/handlers/computer.ts new file mode 100644 index 00000000000..173509381a8 --- /dev/null +++ b/src/cli/handlers/computer.ts @@ -0,0 +1,278 @@ +import type { + ComputerActionResult, + ComputerListAppsResult, + ComputerListWindowsResult, + ComputerProviderCapabilities, + ComputerSnapshotResult +} from '../../shared/runtime-types' +import type { CommandHandler } from '../dispatch' +import { + getOptionalNumberFlag, + getOptionalNonNegativeIntegerFlag, + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlagAllowingEmpty, + getRequiredStringFlag +} from '../flags' +import { + formatComputerAction, + formatGetAppState, + formatListApps, + formatListWindows, + printResult +} from '../format' +import { RuntimeClientError } from '../runtime-client' +import { getComputerCommandTarget } from '../selectors' + +export const COMPUTER_HANDLERS: Record = { + 'computer capabilities': async ({ client, json }) => { + const result = await client.call('computer.capabilities', {}) + printResult(result, json, formatComputerCapabilities) + }, + 'computer list-apps': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.listApps', { + worktree: target.worktree + }) + printResult(result, json, formatListApps) + }, + 'computer permissions': async ({ client, json }) => { + const result = await client.call<{ + platform: NodeJS.Platform + helperAppPath: string | null + openedSettings: boolean + launchedHelper: boolean + permissions?: { id: string; status: string }[] + nextStep?: string | null + }>('computer.permissions', {}) + printResult(result, json, (value) => { + if (value.platform !== 'darwin') { + return 'Computer-use permission setup is only required on macOS.' + } + const firstLine = value.launchedHelper + ? 'Opened Orca Computer Use permission setup.' + : 'Computer Use permissions checked.' + return [ + firstLine, + `Helper app: ${value.helperAppPath}`, + `Permissions: ${value.permissions?.map((permission) => `${permission.id}=${permission.status}`).join(', ') ?? 'unknown'}`, + value.nextStep + ? `Next: ${value.nextStep}` + : 'Computer Use permissions are already granted.', + value.launchedHelper + ? 'Use the Allow buttons or drag "Orca Computer Use" into the macOS permission list.' + : null + ] + .filter((line) => line !== null) + .join('\n') + }) + }, + 'computer list-windows': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.listWindows', target) + printResult(result, json, formatListWindows) + }, + 'computer get-app-state': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.getAppState', { + ...target, + noScreenshot: flags.has('no-screenshot') ? true : undefined, + restoreWindow: flags.has('restore-window') ? true : undefined, + windowId: getOptionalNumberFlag(flags, 'window-id'), + windowIndex: getOptionalNonNegativeIntegerFlag(flags, 'window-index') + }) + printResult(result, json, formatGetAppState) + }, + 'computer click': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.click', { + ...target, + elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'), + x: getOptionalNumberFlag(flags, 'x'), + y: getOptionalNumberFlag(flags, 'y'), + clickCount: getOptionalPositiveIntegerFlag(flags, 'click-count'), + mouseButton: getOptionalStringFlag(flags, 'mouse-button'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('click', value)) + }, + 'computer perform-secondary-action': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.performSecondaryAction', { + ...target, + elementIndex: getRequiredNonNegativeIntegerFlag(flags, 'element-index'), + action: getRequiredStringFlag(flags, 'action'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('perform-secondary-action', value)) + }, + 'computer scroll': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.scroll', { + ...target, + elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'), + x: getOptionalNumberFlag(flags, 'x'), + y: getOptionalNumberFlag(flags, 'y'), + direction: getRequiredStringFlag(flags, 'direction'), + pages: getOptionalPositiveNumberFlag(flags, 'pages'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('scroll', value)) + }, + 'computer drag': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.drag', { + ...target, + fromElementIndex: getOptionalNonNegativeIntegerFlag(flags, 'from-element-index'), + toElementIndex: getOptionalNonNegativeIntegerFlag(flags, 'to-element-index'), + fromX: getOptionalNumberFlag(flags, 'from-x'), + fromY: getOptionalNumberFlag(flags, 'from-y'), + toX: getOptionalNumberFlag(flags, 'to-x'), + toY: getOptionalNumberFlag(flags, 'to-y'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('drag', value)) + }, + 'computer type-text': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.typeText', { + ...target, + text: await getTextPayload(flags, 'text'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('type-text', value)) + }, + 'computer press-key': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.pressKey', { + ...target, + key: getRequiredStringFlag(flags, 'key'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('press-key', value)) + }, + 'computer hotkey': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.hotkey', { + ...target, + key: getRequiredStringFlag(flags, 'key'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('hotkey', value)) + }, + 'computer paste-text': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.pasteText', { + ...target, + text: await getTextPayload(flags, 'text'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('paste-text', value)) + }, + 'computer set-value': async ({ flags, client, cwd, json }) => { + const target = await getComputerCommandTarget(flags, cwd, client) + const result = await client.call('computer.setValue', { + ...target, + elementIndex: getRequiredNonNegativeIntegerFlag(flags, 'element-index'), + value: await getTextPayload(flags, 'value'), + ...getComputerActionObserveFlags(flags) + }) + printResult(result, json, (value) => formatComputerAction('set-value', value)) + } +} + +async function getTextPayload( + flags: Map, + name: 'text' | 'value' +): Promise { + const stdinFlag = `${name}-stdin` + if (flags.has(stdinFlag)) { + if (flags.has(name)) { + throw new RuntimeClientError( + 'invalid_argument', + `Use either --${name} or --${stdinFlag}, not both` + ) + } + return await readStdin() + } + return name === 'value' + ? getRequiredStringFlagAllowingEmpty(flags, name) + : getRequiredStringFlag(flags, name) +} + +async function readStdin(): Promise { + if (process.stdin.isTTY) { + throw new RuntimeClientError('invalid_argument', 'stdin payload requested but stdin is a TTY') + } + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + } + return Buffer.concat(chunks).toString('utf8') +} + +function getOptionalPositiveNumberFlag( + flags: Map, + name: string +): number | undefined { + const value = getOptionalNumberFlag(flags, name) + if (value === undefined) { + return undefined + } + if (value <= 0) { + throw new RuntimeClientError('invalid_argument', `Invalid positive number for --${name}`) + } + return value +} + +function formatComputerCapabilities(value: ComputerProviderCapabilities): string { + return [ + `${value.provider} (${value.platform}, protocol ${value.protocolVersion})`, + ` Apps: list=${value.supports.apps.list} bundleIds=${value.supports.apps.bundleIds} pids=${value.supports.apps.pids}`, + ` Windows: list=${value.supports.windows.list} targetById=${value.supports.windows.targetById} targetByIndex=${value.supports.windows.targetByIndex}`, + ` Observation: screenshot=${value.supports.observation.screenshot} elementFrames=${value.supports.observation.elementFrames} annotatedScreenshot=${value.supports.observation.annotatedScreenshot}`, + ` Actions: ${Object.entries(value.supports.actions) + .filter(([, enabled]) => enabled) + .map(([name]) => name) + .join(', ')}` + ].join('\n') +} + +function getRequiredNonNegativeIntegerFlag( + flags: Map, + name: string +): number { + const value = getOptionalNonNegativeIntegerFlag(flags, name) + if (value === undefined) { + throw new RuntimeClientError('invalid_argument', `Missing required --${name}`) + } + return value +} + +function getComputerActionObserveFlags(flags: Map): { + noScreenshot?: boolean + restoreWindow?: boolean + windowId?: number + windowIndex?: number +} { + const observeFlags: { + noScreenshot?: boolean + restoreWindow?: boolean + windowId?: number + windowIndex?: number + } = { + noScreenshot: flags.has('no-screenshot') ? true : undefined + } + if (flags.has('restore-window')) { + observeFlags.restoreWindow = true + } + const windowId = getOptionalNumberFlag(flags, 'window-id') + if (windowId !== undefined) { + observeFlags.windowId = windowId + } + const windowIndex = getOptionalNonNegativeIntegerFlag(flags, 'window-index') + if (windowIndex !== undefined) { + observeFlags.windowIndex = windowIndex + } + return observeFlags +} diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index be807c28a2f..4520d33888a 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: orchestration CLI handlers share flag-parsing helpers and dispatch/preamble logic; splitting by verb would fragment the RuntimeClient call shape without reducing complexity. */ import type { CommandHandler } from '../dispatch' import { printResult } from '../format' import { @@ -5,14 +6,70 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { RuntimeClientError } from '../runtime-client' import { getTerminalHandle } from '../selectors' +// Why: 15 s is well under Claude Code's empirical ~2 min Bash-tool silence +// budget and generates only ~40 lines per 10 min wait — enough to assure the +// parent process the subprocess is alive without flooding logs. See design +// doc §3.4. +const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000 + +// Why: test-only escape hatch so subprocess tests can verify the feature in +// under 10 s rather than needing a full 15 s silence window. Production users +// should never set this — there is no surface documentation. A bogus value +// falls back to the default rather than disabling the heartbeat. +function resolveHeartbeatIntervalMs(): number { + const raw = process.env.ORCA_HEARTBEAT_INTERVAL_MS + if (!raw) { + return DEFAULT_HEARTBEAT_INTERVAL_MS + } + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_HEARTBEAT_INTERVAL_MS + } + return parsed +} + +function startCheckHeartbeat(deadlineMs: number | undefined): () => void { + const startedAt = Date.now() + const interval = setInterval(() => { + const payload = { + _heartbeat: true, + elapsedMs: Date.now() - startedAt, + deadlineMs: deadlineMs ?? null + } + // Why: `process.stderr.write` is line-flushed per-call in Node, whereas a + // fully-buffered writer would hold all heartbeat lines until exit and + // silently defeat the whole point of the ping. Subprocess test asserts + // this by reading stderr incrementally. See §3.4. + process.stderr.write(`${JSON.stringify(payload)}\n`) + }, resolveHeartbeatIntervalMs()) + if (typeof interval.unref === 'function') { + interval.unref() + } + return () => clearInterval(interval) +} + +// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI can surface a +// clear enum-aware error before the generic RPC Zod "Missing --status" message. +const TASK_STATUS_VALUES = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] as const + type MessageSummary = { id: string from_handle: string to_handle?: string subject: string type?: string + body?: string + payload?: string | null } async function resolveOrchestrationTerminalHandle( @@ -62,18 +119,36 @@ export const ORCHESTRATION_HANDLERS: Record = { 'orchestration check': async ({ flags, client, cwd, json }) => { const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') - const result = await client.call<{ + const wait = flags.has('wait') + const timeoutMs = flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined + + // Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce + // no output for ~2 min (shorter on the non-interactive path). Emit a + // heartbeat line to stderr every HEARTBEAT_INTERVAL_MS while the wait is + // active so the parent process can see the subprocess is still alive. + // Stderr rather than stdout so stdout stays a single final JSON payload, + // and JSON-shaped rather than `# …` so `2>&1 | jq` pipelines still work + // (jq refuses `#`-prefixed lines). See design doc §3.4. + const stopHeartbeat = wait ? startCheckHeartbeat(timeoutMs) : null + type CheckResult = { messages: MessageSummary[] count: number formatted?: string - }>('orchestration.check', { - terminal, - unread: flags.has('unread') ? true : undefined, - types: getOptionalStringFlag(flags, 'types'), - inject: flags.has('inject') ? true : undefined, - wait: flags.has('wait') ? true : undefined, - timeoutMs: flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined - }) + } + let result: Awaited>> + try { + result = await client.call('orchestration.check', { + terminal, + unread: flags.has('unread') ? true : undefined, + all: flags.has('all') ? true : undefined, + types: getOptionalStringFlag(flags, 'types'), + inject: flags.has('inject') ? true : undefined, + wait: wait ? true : undefined, + timeoutMs + }) + } finally { + stopHeartbeat?.() + } printResult(result, json, (r) => { if (r.formatted) { return r.formatted @@ -98,19 +173,36 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration inbox': async ({ flags, client, json }) => { + const full = flags.has('full') const result = await client.call<{ messages: MessageSummary[] count: number }>('orchestration.inbox', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit') + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + terminal: getOptionalStringFlag(flags, 'terminal') }) printResult(result, json, (r) => { if (r.count === 0) { return 'No messages.' } + // Why: default output omits body/payload for at-a-glance sweeps; --full + // prints them verbatim so callers can audit without parsing --json. return r.messages - .map((m) => `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`) - .join('\n') + .map((m) => { + const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` + if (!full) { + return head + } + const parts = [head] + if (m.body && m.body.length > 0) { + parts.push(m.body) + } + if (m.payload) { + parts.push(`[payload] ${m.payload}`) + } + return parts.join('\n') + }) + .join(full ? '\n\n' : '\n') }) }, @@ -128,7 +220,13 @@ export const ORCHESTRATION_HANDLERS: Record = { 'orchestration task-list': async ({ flags, client, json }) => { const result = await client.call<{ - tasks: { id: string; spec: string; status: string }[] + tasks: { + id: string + spec: string + status: string + assignee_handle?: string | null + dispatch_id?: string | null + }[] count: number }>('orchestration.taskList', { status: getOptionalStringFlag(flags, 'status'), @@ -138,16 +236,31 @@ export const ORCHESTRATION_HANDLERS: Record = { if (r.count === 0) { return 'No tasks.' } - return r.tasks.map((t) => `${t.id} [${t.status}] ${t.spec.slice(0, 60)}`).join('\n') + return r.tasks + .map((t) => { + const head = `${t.id} [${t.status}] ${t.spec.slice(0, 60)}` + if (t.status === 'dispatched' && t.assignee_handle) { + return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` + } + return head + }) + .join('\n') }) }, 'orchestration task-update': async ({ flags, client, json }) => { + const status = getRequiredStringFlag(flags, 'status') + if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` + ) + } const result = await client.call<{ task: { id: string; status: string } }>( 'orchestration.taskUpdate', { id: getRequiredStringFlag(flags, 'id'), - status: getRequiredStringFlag(flags, 'status'), + status, result: getOptionalStringFlag(flags, 'result') } ) @@ -156,29 +269,95 @@ export const ORCHESTRATION_HANDLERS: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await client.call<{ - dispatch: { id: string; task_id: string; status: string } - }>('orchestration.dispatch', { - task: getRequiredStringFlag(flags, 'task'), - to: getRequiredStringFlag(flags, 'to'), - from, - inject: flags.has('inject') ? true : undefined, - devMode: isDevCliInvocation() - }) - printResult( - result, - json, - (r) => `Dispatched ${r.dispatch.task_id} -> ${r.dispatch.id} [${r.dispatch.status}]` - ) - }, - - 'orchestration dispatch-show': async ({ flags, client, json }) => { + const dryRun = flags.has('dry-run') ? true : undefined + const returnPreamble = flags.has('return-preamble') ? true : undefined + // Why: --to is only required for non-dry-run; the RPC handler re-enforces. + const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') const result = await client.call<{ dispatch: { id: string; task_id: string; status: string } | null - }>('orchestration.dispatchShow', { - task: getRequiredStringFlag(flags, 'task') + injected?: boolean + dryRun?: boolean + preamble?: string + }>('orchestration.dispatch', { + task: getRequiredStringFlag(flags, 'task'), + to, + from, + inject: flags.has('inject') ? true : undefined, + dryRun, + returnPreamble, + devMode: isDevCliInvocation() }) printResult(result, json, (r) => { + if (r.dryRun) { + return r.preamble ?? '' + } + const base = `Dispatched ${r.dispatch?.task_id} -> ${r.dispatch?.id} [${r.dispatch?.status}]` + return r.preamble ? `${base}\n\n--- Preamble ---\n${r.preamble}` : base + }) + }, + + 'orchestration ask': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const timeoutMs = flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : 600_000 + const result = await client.call<{ + answer: string | null + messageId: string | null + threadId: string + timedOut: boolean + }>( + 'orchestration.ask', + { + to: getRequiredStringFlag(flags, 'to'), + question: getRequiredStringFlag(flags, 'question'), + options: getOptionalStringFlag(flags, 'options'), + timeoutMs: flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined, + from + }, + // Why: the runtime's `waitForMessage` can block up to `timeoutMs`, but + // the RPC transport has its own 60s default timeout that would fire + // first. Extend the per-call timeout by a small grace window so the + // RPC doesn't abort before the runtime's internal timeout resolves. + { timeoutMs: timeoutMs + 5_000 } + ) + // Why: deliberate bypass of `printResult`. `--json` on `ask` emits a + // single-line bare JSON object (no RPC envelope, no multi-line pretty- + // print) so workers can pipe `orca orchestration ask … --json | jq -r + // .answer` without reaching into a `result` envelope. This diverges from + // every other orchestration verb; called out in the commit message and + // guarded by a unit test in orchestration.test.ts. + if (json) { + console.log(JSON.stringify(result.result)) + } else if (result.result.answer !== null) { + console.log(result.result.answer) + } + if (result.result.timedOut) { + if (!json) { + console.error(`ask timeout after ${timeoutMs}ms (thread ${result.result.threadId})`) + } + process.exitCode = 1 + } + }, + + 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { + const showPreamble = flags.has('preamble') ? true : undefined + // Why: resolve --from when previewing so the preamble embeds a real + // coordinator handle, matching what an actual dispatch would produce. + const from = showPreamble + ? await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + : undefined + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } | null + preamble?: string + }>('orchestration.dispatchShow', { + task: getRequiredStringFlag(flags, 'task'), + preamble: showPreamble, + from, + devMode: isDevCliInvocation() + }) + printResult(result, json, (r) => { + if (r.preamble && showPreamble) { + return r.preamble + } if (!r.dispatch) { return 'No dispatch context found.' } diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index c397021a8cf..e59ad46e712 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -119,7 +119,8 @@ export const TERMINAL_HANDLERS: Record = { const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { worktree: await getBrowserWorktreeSelector(flags, cwd, client), command: getOptionalStringFlag(flags, 'command'), - title: getOptionalStringFlag(flags, 'title') + title: getOptionalStringFlag(flags, 'title'), + focus: flags.get('focus') === true }) printResult(result, json, formatTerminalCreate) }, diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 7377f817c25..5dfe9873d60 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -59,7 +59,8 @@ export const WORKTREE_HANDLERS: Record = { baseBranch: getOptionalStringFlag(flags, 'base-branch'), linkedIssue: getOptionalNumberFlag(flags, 'issue'), comment: getOptionalStringFlag(flags, 'comment'), - runHooks: flags.get('run-hooks') === true + runHooks: flags.get('run-hooks') === true, + activate: flags.get('activate') === true || flags.get('run-hooks') === true } ) printHookWarning(result.result, json) diff --git a/src/cli/help.ts b/src/cli/help.ts index 2e8f6ddb9f5..411ddd98715 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */ import type { CommandSpec } from './args' import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args' @@ -32,7 +33,7 @@ Terminals: terminal send Send input to a live terminal terminal wait Wait for a terminal condition (exit, tui-idle) terminal stop Stop terminals for a worktree - terminal create Create a new terminal tab in a worktree + terminal create Create a terminal session in a worktree terminal rename Set or clear the title of a terminal tab terminal split Split an existing terminal pane terminal switch Bring a terminal tab to the foreground @@ -56,9 +57,33 @@ Orchestration: orchestration gate-list List decision gates orchestration reset Reset orchestration state +Computer Use: + computer permissions Open the macOS permission setup for computer-use + computer list-apps List running apps available to computer-use + computer list-windows List visible windows for a target app + computer get-app-state Capture a compact accessibility snapshot of an app + computer click Click an app element or window coordinate + computer perform-secondary-action Run an advertised accessibility action + computer scroll Scroll an app element + computer drag Drag between app elements or window coordinates + computer type-text Type literal text at the current app focus + computer press-key Press a key using xdotool-style syntax + computer hotkey Press a shortcut combination such as CmdOrCtrl+A + computer paste-text Paste text through the native clipboard path + computer set-value Set the value of a settable app element + Browser Automation: tab create Create a new browser tab (navigates to --url) tab list List open browser tabs + tab show Show one browser tab by page id + tab current Show the current browser tab + tab profile list List browser session profiles + tab profile create Create a browser session profile + tab profile delete Delete a browser session profile + tab profile set Switch a browser tab to a different profile + tab profile show Show the profile bound to a browser tab + tab profile use-default Switch a browser tab back to the default profile + tab profile clone Clone a browser tab into another profile tab switch Switch the active browser tab by --index or --page tab close Close a browser tab by --index/--page or the current tab snapshot Accessibility snapshot with element refs (e.g. @e1, @e2) @@ -115,7 +140,7 @@ Common Commands: orca open [--json] orca status [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--json] + orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] @@ -127,7 +152,7 @@ Common Commands: orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] orca terminal stop --worktree [--json] - orca terminal create [--worktree ] [--title ] [--command ] [--json] + orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] orca terminal switch [--terminal ] [--json] orca terminal close [--terminal ] [--json] @@ -183,6 +208,8 @@ Browser Options: --amount Scroll distance in pixels (default: viewport height) --index Tab index (from \`tab list\`) --page Stable browser page id (preferred for concurrent workflows) + --profile Browser profile id + --show-profile Include the tab's browser profile in text output --format Screenshot image format --from Drag source element ref --to Drag target element ref @@ -202,7 +229,10 @@ Examples: $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json $ orca terminal send --terminal term_123 --text "hi" --enter $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json - $ orca tab create --url https://example.com + $ orca tab current --json + $ orca tab show --page page_123 --json + $ orca tab create --url https://example.com --profile work + $ orca tab profile clone --page page_123 --profile work --json $ orca snapshot $ orca click --element e3 $ orca fill --element e5 --value "hello" @@ -276,27 +306,50 @@ export function formatFlagHelp(flag: string): string { command: '--command Command to run in the terminal on startup', comment: '--comment Comment stored in Orca metadata', cursor: '--cursor Line cursor from a previous read (returns only new output)', - direction: '--direction Direction: horizontal|vertical (split) or up|down (scroll)', + action: '--action Secondary accessibility action name', + activate: '--activate Reveal the new worktree in the Orca app', + app: '--app App name, bundle ID, or pid:N', + direction: + '--direction Direction: up|down|left|right for scroll, horizontal|vertical for split', 'display-name': '--display-name Override the Orca display name', + 'element-index': '--element-index Element index from get-app-state', title: '--title Custom title for the terminal tab (omit to reset)', enter: '--enter Append Enter after sending text', force: '--force Force worktree removal when supported', + focus: '--focus Reveal the created terminal session in Orca', for: '--for exit|tui-idle Wait condition to satisfy', + 'from-element-index': '--from-element-index Source element index from get-app-state', + 'from-x': '--from-x Source window-local x coordinate', + 'from-y': '--from-y Source window-local y coordinate', help: '--help Show this help message', interrupt: '--interrupt Send as an interrupt-style input when supported', issue: '--issue Linked GitHub issue number', json: '--json Emit machine-readable JSON', + key: '--key Key or combo to press, e.g. Escape or CmdOrCtrl+L', limit: '--limit Maximum number of rows to return', + 'mouse-button': '--mouse-button Mouse button: left, right, or middle', name: '--name Name for the new worktree', + 'no-screenshot': '--no-screenshot Skip screenshot capture after the operation', + pages: '--pages Number of scroll pages', path: '--path Filesystem path to the repo', query: '--query Search text for matching refs', ref: '--ref Base ref to persist for the repo', repo: '--repo Repo selector such as id:, name:, or path:', + 'restore-window': + '--restore-window Bring the target app/window forward before the operation', + session: '--session Snapshot namespace for a related computer-use workflow', terminal: '--terminal Runtime-issued terminal handle', - text: '--text Text to send to the terminal', + text: '--text Text payload to send or type', + 'text-stdin': '--text-stdin Read text payload from stdin', 'timeout-ms': '--timeout-ms Maximum wait time before timing out', + 'to-element-index': '--to-element-index Destination element index from get-app-state', + 'to-x': '--to-x Destination window-local x coordinate', + 'to-y': '--to-y Destination window-local y coordinate', worktree: '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current', + 'value-stdin': '--value-stdin Read set-value payload from stdin', + 'window-id': '--window-id Target a window id from list-windows', + 'window-index': '--window-index Target a window index from list-windows', // Browser automation flags element: '--element Element ref from snapshot (e.g. e3)', url: '--url URL to navigate to', @@ -306,6 +359,8 @@ export function formatFlagHelp(flag: string): string { amount: '--amount Scroll distance in pixels', index: '--index Tab index to switch to', page: '--page Stable browser page id from `orca tab list --json`', + profile: '--profile Browser profile id', + 'show-profile': '--show-profile Include tab profile in text output', format: '--format Screenshot image format' } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 8783968bf24..668354a2607 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: CLI parser tests share one mocked runtime client and fixture queue; splitting this file would duplicate setup and make command coverage harder to audit. */ import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -145,6 +146,91 @@ describe('orca cli worktree awareness', () => { }) }) + it('passes explicit activation through worktree.create', async () => { + queueFixtures( + callMock, + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1') + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--activate', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.create', { + repo: 'id:repo-1', + name: 'feature', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: false, + activate: true + }) + }) + + it('opts into setup and activation when worktree.create runs hooks', async () => { + queueFixtures( + callMock, + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1') + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--run-hooks', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.create', { + repo: 'id:repo-1', + name: 'feature', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: true, + activate: true + }) + }) + + it('passes explicit focus through terminal.create', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_create', { + terminal: { + handle: 'term_1', + worktreeId: 'repo-1::/tmp/repo/feature', + title: 'RUNNER' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'terminal', + 'create', + '--worktree', + 'path:/tmp/repo/feature', + '--title', + 'RUNNER', + '--focus', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('terminal.create', { + worktree: 'path:/tmp/repo/feature', + command: undefined, + title: 'RUNNER', + focus: true + }) + }) + it('uses the resolved enclosing worktree for other worktree consumers', async () => { queueFixtures( callMock, @@ -197,6 +283,31 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) + it('rejects unknown task-update status with an enum-aware error', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_coord' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['orchestration', 'task-update', '--id', 'task_x', '--status', 'complete'], + '/tmp/repo' + ) + + const output = [...errSpy.mock.calls, ...logSpy.mock.calls] + .flat() + .map((v) => (typeof v === 'string' ? v : JSON.stringify(v))) + .join('\n') + expect(output).toContain("invalid status 'complete'") + expect(output).toContain('pending, ready, dispatched, completed, failed, blocked') + expect(callMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + + // Reset exitCode so subsequent tests don't inherit the failure. + process.exitCode = priorExitCode + errSpy.mockRestore() + }) + it('passes dev mode to injected orchestration dispatches', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_sender' process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev' diff --git a/src/cli/orchestration.subprocess.test.ts b/src/cli/orchestration.subprocess.test.ts new file mode 100644 index 00000000000..c8648ceafe6 --- /dev/null +++ b/src/cli/orchestration.subprocess.test.ts @@ -0,0 +1,158 @@ +// Why: subprocess-level test for the CLI heartbeat behavior described in +// design doc §3.4. Spawns the real compiled CLI with no TTY, points it at a +// real in-process runtime via ORCA_USER_DATA_PATH, and asserts: +// - the first heartbeat line appears on stderr well under Claude Code's +// ~2 min Bash-tool silence budget (we verify with a shortened interval; +// production uses 15 s via the same code path) +// - ≥3 heartbeats arrive during the wait window +// - stderr is line-flushed (we observe each heartbeat as a separate chunk +// before the process exits — not in one burst at the end) +// - stdout stays a single clean JSON payload (no heartbeats leak to stdout) +// - a `jq "select(._heartbeat|not)"` filter on the merged stream would +// yield exactly the final result +// +// This test is skipped if the CLI hasn't been built yet (out/cli/index.js +// missing) so `pnpm test` works on a fresh checkout without requiring a prior +// `pnpm run build:cli`. The verification gate explicitly builds the CLI +// before running this file. +import { spawn } from 'child_process' +import { existsSync, mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from '../main/runtime/orca-runtime' +import { OrchestrationDb } from '../main/runtime/orchestration/db' +import { OrcaRuntimeRpcServer } from '../main/runtime/runtime-rpc' + +// Why: Vitest runs tests with `process.cwd()` pinned to the repo root, so +// join against it to locate the compiled CLI regardless of where this test +// file itself lives. +const CLI_PATH = join(process.cwd(), 'out', 'cli', 'index.js') + +const describeIfBuilt = existsSync(CLI_PATH) ? describe : describe.skip + +describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { + it('emits newline-flushed JSON heartbeats to stderr while waiting', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-sub-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) + await server.start() + + try { + // Why: use the ORCA_HEARTBEAT_INTERVAL_MS escape hatch to shrink the + // test to ~1 s wall time. Production callers never set this; the + // production default (15 s) is exercised by §3.4's own unit tests + // and by the fact that this same code path runs with the real + // constant when the env var is absent. + const heartbeatMs = 200 + const waitTimeoutMs = 1200 + + const child = spawn( + process.execPath, + [ + CLI_PATH, + 'orchestration', + 'check', + '--wait', + '--timeout-ms', + String(waitTimeoutMs), + '--json' + ], + { + env: { + ...process.env, + ORCA_USER_DATA_PATH: userDataPath, + ORCA_TERMINAL_HANDLE: 'term_nobody', + ORCA_HEARTBEAT_INTERVAL_MS: String(heartbeatMs) + }, + // Why: explicit pipe for all three fds so we can watch stderr + // in real time; no TTY attached (Bash-tool parity). + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + + const stderrChunks: { at: number; data: string }[] = [] + const stdoutChunks: { at: number; data: string }[] = [] + const startedAt = Date.now() + child.stderr.setEncoding('utf8') + child.stdout.setEncoding('utf8') + child.stderr.on('data', (d) => stderrChunks.push({ at: Date.now() - startedAt, data: d })) + child.stdout.on('data', (d) => stdoutChunks.push({ at: Date.now() - startedAt, data: d })) + + const exitCode = await new Promise((resolveExit, rejectExit) => { + child.once('exit', (code) => resolveExit(code ?? 1)) + child.once('error', rejectExit) + }) + + expect(exitCode).toBe(0) + + const stderr = stderrChunks.map((c) => c.data).join('') + const stdout = stdoutChunks.map((c) => c.data).join('') + + const heartbeatLines = stderr + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => { + try { + return JSON.parse(line) as Record + } catch { + return null + } + }) + .filter((p): p is Record => p !== null && p._heartbeat === true) + + // ≥3 heartbeats in a 1.2s window with a 200ms interval + expect(heartbeatLines.length).toBeGreaterThanOrEqual(3) + expect(heartbeatLines[0]).toHaveProperty('elapsedMs') + expect(heartbeatLines[0]).toHaveProperty('deadlineMs', waitTimeoutMs) + + // Why: first heartbeat must arrive within one interval + scheduler + // slack (300ms is generous); if the stream were fully buffered we'd + // see everything only after exit. + const firstHeartbeatChunk = stderrChunks.find((c) => c.data.includes('_heartbeat')) + expect(firstHeartbeatChunk).toBeDefined() + expect(firstHeartbeatChunk!.at).toBeLessThan(heartbeatMs + 300) + + // Why: line-flushing proof — the *first* heartbeat chunk must arrive + // strictly before the exit chunk; i.e. we got at least two separate + // stderr data events (heartbeat + final). A single-chunk delivery + // would indicate stderr was buffered until exit. + const lastStderrAt = stderrChunks.at(-1)?.at ?? 0 + const firstStderrAt = stderrChunks.at(0)?.at ?? 0 + expect(lastStderrAt).toBeGreaterThan(firstStderrAt) + + // Stdout: exactly one JSON payload, the terminal result. No heartbeats + // leak, and the content parses as valid JSON. + const stdoutTrimmed = stdout.trim() + const stdoutPayload = JSON.parse(stdoutTrimmed) as Record + expect(stdoutPayload).not.toHaveProperty('_heartbeat') + expect(stdoutTrimmed).not.toContain('_heartbeat') + // Why: result should be an RPC success envelope with the expected + // shape. `count: 0` and `messages: []` because the wait timed out + // with no message for term_nobody. + expect(stdoutPayload).toMatchObject({ ok: true }) + + // Why: the heartbeats-on-stderr design is meant to pair with shell + // filters like `2>&1 | jq "select(._heartbeat|not)"`. jq is + // line-oriented by default, but also accepts pretty-printed JSON + // across multiple lines. What matters here is that every + // heartbeat line on stderr is a standalone JSON object (so jq can + // match it) and doesn't span multiple lines — assert that each + // heartbeat is a single-line JSON with no embedded newlines. + for (const line of stderr.split('\n')) { + if (line.trim().length === 0) { + continue + } + if (line.includes('_heartbeat')) { + expect(() => JSON.parse(line)).not.toThrow() + expect(line).not.toContain('\n') + } + } + } finally { + db.close() + await server.stop() + } + }, 30_000) +}) diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 74102e01e05..2064b937887 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -35,10 +35,12 @@ function writeMetadata( JSON.stringify({ runtimeId: 'runtime-1', pid, - transport: { - kind: 'unix', - endpoint - }, + transports: [ + { + kind: 'unix', + endpoint + } + ], authToken, startedAt: 1 }), diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index e256bcdabd0..ae65cd4f34c 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -5,6 +5,14 @@ import { getCliStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +// Why: for `orchestration.check --wait` the caller's method-level +// `params.timeoutMs` is the inner waiter budget; we extend the client-side +// socket timeout to `timeoutMs + GRACE_MS` so the client's own idle timer +// never fires before the server-side waiter has had a chance to resolve and +// emit its terminal frame. The 10 s grace absorbs round-trip + one final +// keepalive window. See design doc §3.1. +const LONG_POLL_CLIENT_GRACE_MS = 10_000 + export class RuntimeClient { private readonly userDataPath: string private readonly requestTimeoutMs: number @@ -25,18 +33,30 @@ export class RuntimeClient { } ): Promise> { const metadata = readMetadata(this.userDataPath) - const response = await sendRequest( - metadata, - method, - params, - options?.timeoutMs ?? this.requestTimeoutMs - ) + const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params) + const response = await sendRequest(metadata, method, params, effectiveTimeoutMs) if (!response.ok) { throw new RuntimeRpcFailureError(response) } return response } + // Why: centralises the per-method timeout policy. `orchestration.check` with + // `wait: true` is the only long-poll today, and its inner waiter budget + // lives in `params.timeoutMs`. We widen the client-side socket timeout to + // `timeoutMs + grace` so it doesn't fire before the server has a chance to + // resolve. Without this, a 5 min wait would still die at the 60 s default. + // See design doc §3.1. + private resolveMethodTimeoutMs(method: string, params?: unknown): number { + if (method === 'orchestration.check' && isWaitingCheck(params)) { + const inner = Number((params as { timeoutMs?: unknown }).timeoutMs) + if (Number.isFinite(inner) && inner > 0) { + return Math.max(inner + LONG_POLL_CLIENT_GRACE_MS, this.requestTimeoutMs) + } + } + return this.requestTimeoutMs + } + async getCliStatus(): Promise> { return getCliStatus(this.userDataPath) } @@ -67,3 +87,12 @@ export class RuntimeClient { function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } + +function isWaitingCheck(params: unknown): boolean { + return ( + typeof params === 'object' && + params !== null && + 'wait' in params && + (params as { wait: unknown }).wait === true + ) +} diff --git a/src/cli/runtime/envelope-schema.test.ts b/src/cli/runtime/envelope-schema.test.ts index 57f92ada298..b7315644f4b 100644 --- a/src/cli/runtime/envelope-schema.test.ts +++ b/src/cli/runtime/envelope-schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { RuntimeRpcEnvelopeSchema } from './envelope-schema' +import { RuntimeRpcEnvelopeSchema, isKeepaliveFrame } from './envelope-schema' describe('RuntimeRpcEnvelopeSchema', () => { it('accepts a well-formed success envelope', () => { @@ -57,4 +57,37 @@ describe('RuntimeRpcEnvelopeSchema', () => { const parsed = RuntimeRpcEnvelopeSchema.safeParse({ hello: 'world' }) expect(parsed.success).toBe(false) }) + + it('accepts a keepalive frame', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ _keepalive: true }) + expect(parsed.success).toBe(true) + }) + + it('rejects a keepalive frame with _keepalive !== true', () => { + const parsed = RuntimeRpcEnvelopeSchema.safeParse({ _keepalive: false }) + expect(parsed.success).toBe(false) + }) +}) + +describe('isKeepaliveFrame', () => { + it('returns true for a well-formed keepalive', () => { + expect(isKeepaliveFrame({ _keepalive: true })).toBe(true) + }) + + it('returns false for a success envelope', () => { + expect(isKeepaliveFrame({ id: 'x', ok: true, result: {}, _meta: { runtimeId: 'r' } })).toBe( + false + ) + }) + + it('returns false for non-object inputs', () => { + expect(isKeepaliveFrame(null)).toBe(false) + expect(isKeepaliveFrame(undefined)).toBe(false) + expect(isKeepaliveFrame('keepalive')).toBe(false) + }) + + it('returns false when _keepalive is not strictly true', () => { + expect(isKeepaliveFrame({ _keepalive: 1 })).toBe(false) + expect(isKeepaliveFrame({ _keepalive: 'true' })).toBe(false) + }) }) diff --git a/src/cli/runtime/envelope-schema.ts b/src/cli/runtime/envelope-schema.ts index eeec3536005..f674aab11af 100644 --- a/src/cli/runtime/envelope-schema.ts +++ b/src/cli/runtime/envelope-schema.ts @@ -36,4 +36,27 @@ const Failure = z.object({ _meta: MetaFailure }) -export const RuntimeRpcEnvelopeSchema = z.discriminatedUnion('ok', [Success, Failure]) +// Why: transport-layer keepalive frame (server→client only). Not a terminal +// frame — the client reads past it and keeps waiting for the real +// success/failure. `id` and `_meta` are deliberately absent: keepalives carry +// no method-level semantics and aren't tied to a particular request (one +// connection handles one request today). See design doc §3.1. +const Keepalive = z.object({ + _keepalive: z.literal(true) +}) + +// Why: switched from z.discriminatedUnion('ok', …) to z.union because +// keepalives have no `ok` field. Client code must branch on +// `'_keepalive' in frame` before treating the frame as Success/Failure. +export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive]) + +export type RuntimeRpcKeepaliveFrame = z.infer + +export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame { + return ( + typeof frame === 'object' && + frame !== null && + '_keepalive' in frame && + (frame as { _keepalive: unknown })._keepalive === true + ) +} diff --git a/src/cli/runtime/metadata.ts b/src/cli/runtime/metadata.ts index cd690ab0f14..df93adad8b5 100644 --- a/src/cli/runtime/metadata.ts +++ b/src/cli/runtime/metadata.ts @@ -1,14 +1,18 @@ import { homedir } from 'os' import { join } from 'path' import { readFileSync } from 'fs' -import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { + findTransport, + getRuntimeMetadataPath, + type RuntimeMetadata +} from '../../shared/runtime-bootstrap' import { RuntimeClientError } from './types' export function readMetadata(userDataPath: string): RuntimeMetadata { const metadataPath = getRuntimeMetadataPath(userDataPath) try { const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null - if (!metadata?.transport || !metadata.authToken) { + if (!metadata || !findTransport(metadata, 'unix', 'named-pipe') || !metadata.authToken) { throw new RuntimeClientError( 'runtime_unavailable', `Orca runtime metadata is incomplete at ${metadataPath}` diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 00daf4bfec6..3a0f445a40f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -7,7 +7,7 @@ export async function getCliStatus( userDataPath: string ): Promise> { const metadata = tryReadMetadata(userDataPath) - if (!metadata?.transport || !metadata.authToken) { + if (!metadata?.transports?.length || !metadata.authToken) { return buildCliStatusResponse({ app: { running: false, diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index bc39de0d332..ca8ca746274 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,7 +1,7 @@ import { createConnection } from 'net' import { randomUUID } from 'crypto' -import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' -import { RuntimeRpcEnvelopeSchema } from './envelope-schema' +import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema' import { RuntimeClientError, type RuntimeRpcResponse } from './types' export async function sendRequest( @@ -11,11 +11,26 @@ export async function sendRequest( timeoutMs: number ): Promise> { return await new Promise((resolve, reject) => { - const socket = createConnection(getTransportEndpoint(metadata.transport!)) + const transport = findTransport(metadata, 'unix', 'named-pipe') + if (!transport) { + reject( + new RuntimeClientError( + 'runtime_unavailable', + 'No compatible transport found in Orca runtime metadata.' + ) + ) + return + } + const socket = createConnection(transport.endpoint) let buffer = '' + let settled = false const requestId = randomUUID() const timeout = setTimeout(() => { + if (settled) { + return + } + settled = true socket.destroy() reject( new RuntimeClientError( @@ -25,28 +40,72 @@ export async function sendRequest( ) }, timeoutMs) + const finish = ( + result: { ok: true; response: RuntimeRpcResponse } | { ok: false; error: Error } + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + socket.end() + if (result.ok) { + resolve(result.response) + } else { + reject(result.error) + } + } + socket.setEncoding('utf8') socket.once('error', () => { - clearTimeout(timeout) - reject( - new RuntimeClientError( + finish({ + ok: false, + error: new RuntimeClientError( 'runtime_unavailable', 'Could not connect to the running Orca app. Restart Orca and try again.' ) - ) + }) }) socket.on('data', (chunk) => { buffer += chunk - const newlineIndex = buffer.indexOf('\n') - if (newlineIndex === -1) { - return - } - const message = buffer.slice(0, newlineIndex) - socket.end() - clearTimeout(timeout) - let response: RuntimeRpcResponse - try { - const raw: unknown = JSON.parse(message) + // Why: the server may interleave `{"_keepalive":true}\n` frames with the + // final success/failure frame to keep both idle timers alive during a + // long-poll (see design doc §3.1). Read frames in a loop until we see a + // terminal frame. Each keepalive refreshes the client-side timer so a + // 10 min wait doesn't trip the 60 s default ceiling. + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1 && !settled) { + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) + if (line.trim().length === 0) { + newlineIndex = buffer.indexOf('\n') + continue + } + + let raw: unknown + try { + raw = JSON.parse(line) + } catch { + finish({ + ok: false, + error: new RuntimeClientError( + 'invalid_runtime_response', + 'The Orca runtime returned an invalid response frame.' + ) + }) + return + } + + // Fast-path: ignore keepalives without running the full schema. + // setTimeout().refresh() is stable since Node 10 (Orca ships on + // Node 20+ via Electron and the standalone CLI targets the same + // major). See §7 risk #9. + if (isKeepaliveFrame(raw)) { + timeout.refresh() + newlineIndex = buffer.indexOf('\n') + continue + } + // Why: validate the envelope shape (id, ok, result/error, _meta) at // the decode boundary so version skew between the CLI and the Orca // main runtime surfaces as a single invalid_runtime_response instead @@ -54,43 +113,49 @@ export async function sendRequest( // unknown — the TResult generic is the caller's responsibility. const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw) if (!parsed.success) { - reject( - new RuntimeClientError( + finish({ + ok: false, + error: new RuntimeClientError( 'invalid_runtime_response', 'The Orca runtime returned an invalid response frame.' ) - ) + }) return } - response = parsed.data as RuntimeRpcResponse - } catch { - reject( - new RuntimeClientError( - 'invalid_runtime_response', - 'The Orca runtime returned an invalid response frame.' - ) - ) + + // Narrow out keepalive (already filtered above) so TS can see a + // Success|Failure shape here. + const frame = parsed.data + if ('_keepalive' in frame) { + timeout.refresh() + newlineIndex = buffer.indexOf('\n') + continue + } + + const response = frame as RuntimeRpcResponse + if (response.id !== requestId) { + finish({ + ok: false, + error: new RuntimeClientError( + 'invalid_runtime_response', + 'The Orca runtime returned a mismatched response id.' + ) + }) + return + } + if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) { + finish({ + ok: false, + error: new RuntimeClientError( + 'runtime_unavailable', + 'The Orca runtime changed while the request was in flight. Retry the command.' + ) + }) + return + } + finish({ ok: true, response }) return } - if (response.id !== requestId) { - reject( - new RuntimeClientError( - 'invalid_runtime_response', - 'The Orca runtime returned a mismatched response id.' - ) - ) - return - } - if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) { - reject( - new RuntimeClientError( - 'runtime_unavailable', - 'The Orca runtime changed while the request was in flight. Retry the command.' - ) - ) - return - } - resolve(response) }) socket.on('connect', () => { socket.write( @@ -104,7 +169,3 @@ export async function sendRequest( }) }) } - -function getTransportEndpoint(transport: RuntimeTransportMetadata): string { - return transport.endpoint -} diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index dfdfa7377a3..14420cebb69 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,5 +1,5 @@ import { isAbsolute, relative, resolve as resolvePath } from 'path' -import type { RuntimeWorktreeListResult } from '../shared/runtime-types' +import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' import { getOptionalStringFlag, getRequiredStringFlag } from './flags' @@ -9,6 +9,12 @@ export type BrowserCliTarget = { page?: string } +export type ComputerCliTarget = { + session?: string + worktree?: string + app?: ComputerAppQuery +} + export function buildCurrentWorktreeSelector(cwd: string): string { return `path:${resolvePath(cwd)}` } @@ -150,3 +156,19 @@ export async function getBrowserCommandTarget( worktree: normalizeWorktreeSelector(explicitWorktree, cwd) } } + +export async function getComputerCommandTarget( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + const app = getOptionalStringFlag(flags, 'app') + const session = getOptionalStringFlag(flags, 'session') + if (session) { + return { session, app } + } + return { + app, + worktree: await getBrowserWorktreeSelector(flags, cwd, client) + } +} diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 8a1e0289330..26c81da288f 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -154,20 +154,74 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ { path: ['tab', 'list'], summary: 'List open browser tabs', - usage: 'orca tab list [--worktree ] [--json]', + usage: 'orca tab list [--worktree ] [--show-profile] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'show-profile'] + }, + { + path: ['tab', 'show'], + summary: 'Show one browser tab by page id', + usage: 'orca tab show --page [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['tab', 'current'], + summary: 'Show the current browser tab', + usage: 'orca tab current [--worktree ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'worktree'] }, { path: ['tab', 'switch'], summary: 'Switch the active browser tab', - usage: 'orca tab switch (--index | --page ) [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree'] + usage: 'orca tab switch (--index | --page ) [--worktree ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree', 'focus'] }, { path: ['tab', 'create'], summary: 'Create a new browser tab in the current worktree', - usage: 'orca tab create [--url ] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] + usage: 'orca tab create [--url ] [--worktree ] [--profile ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree', 'profile'] + }, + { + path: ['tab', 'profile', 'list'], + summary: 'List browser session profiles available to browser tabs', + usage: 'orca tab profile list [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['tab', 'profile', 'create'], + summary: 'Create a browser session profile for browser tabs', + usage: 'orca tab profile create --label [--scope ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope'] + }, + { + path: ['tab', 'profile', 'delete'], + summary: 'Delete a browser session profile used by browser tabs', + usage: 'orca tab profile delete --profile [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'profile'] + }, + { + path: ['tab', 'profile', 'set'], + summary: 'Switch a browser tab to a different browser profile', + usage: 'orca tab profile set (--page | --worktree ) --profile [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'profile', 'worktree'] + }, + { + path: ['tab', 'profile', 'show'], + summary: 'Show the browser profile bound to a tab', + usage: 'orca tab profile show --page [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['tab', 'profile', 'use-default'], + summary: 'Switch a browser tab back to the default browser profile', + usage: 'orca tab profile use-default --page [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['tab', 'profile', 'clone'], + summary: 'Clone a browser tab into a different browser profile', + usage: 'orca tab profile clone --profile [--page ] [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'profile', 'worktree'] }, { path: ['tab', 'close'], diff --git a/src/cli/specs/computer.test.ts b/src/cli/specs/computer.test.ts new file mode 100644 index 00000000000..11f4d4cee74 --- /dev/null +++ b/src/cli/specs/computer.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { COMPUTER_COMMAND_SPECS } from './computer' + +describe('computer command specs', () => { + it('allows explicit window targeting on action commands', () => { + const actionSpecs = COMPUTER_COMMAND_SPECS.filter((spec) => + [ + 'computer click', + 'computer drag', + 'computer hotkey', + 'computer paste-text', + 'computer perform-secondary-action', + 'computer press-key', + 'computer scroll', + 'computer set-value', + 'computer type-text' + ].includes(spec.path.join(' ')) + ) + + expect(actionSpecs).not.toHaveLength(0) + for (const spec of actionSpecs) { + expect(spec.allowedFlags).toEqual(expect.arrayContaining(['window-id', 'window-index'])) + } + }) +}) diff --git a/src/cli/specs/computer.ts b/src/cli/specs/computer.ts new file mode 100644 index 00000000000..93366c976b9 --- /dev/null +++ b/src/cli/specs/computer.ts @@ -0,0 +1,124 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +const COMPUTER_FLAGS = [...GLOBAL_FLAGS, 'worktree', 'session', 'app'] +const COMPUTER_WINDOW_TARGET_FLAGS = ['window-id', 'window-index'] +const COMPUTER_ACTION_FLAGS = [ + ...COMPUTER_FLAGS, + ...COMPUTER_WINDOW_TARGET_FLAGS, + 'restore-window', + 'no-screenshot' +] + +export const COMPUTER_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['computer', 'capabilities'], + summary: 'Show computer-use provider capabilities', + usage: 'orca computer capabilities [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['computer', 'list-apps'], + summary: 'List running apps available to computer-use', + usage: 'orca computer list-apps [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree'] + }, + { + path: ['computer', 'permissions'], + summary: 'Open computer-use permission setup', + usage: 'orca computer permissions [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['computer', 'list-windows'], + summary: 'List windows for an app available to computer-use', + usage: + 'orca computer list-windows --app [--worktree | --session ] [--json]', + allowedFlags: COMPUTER_FLAGS + }, + { + path: ['computer', 'get-app-state'], + summary: 'Capture a compact accessibility snapshot of an app', + usage: + 'orca computer get-app-state --app [--window-id | --window-index ] [--worktree | --session ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [...COMPUTER_ACTION_FLAGS, ...COMPUTER_WINDOW_TARGET_FLAGS, 'restore-window'] + }, + { + path: ['computer', 'click'], + summary: 'Click an app element or window coordinate', + usage: + 'orca computer click --app (--element-index | --x --y ) [--window-id | --window-index ] [--click-count ] [--mouse-button ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [ + ...COMPUTER_ACTION_FLAGS, + 'element-index', + 'x', + 'y', + 'click-count', + 'mouse-button' + ] + }, + { + path: ['computer', 'perform-secondary-action'], + summary: 'Perform an advertised secondary accessibility action', + usage: + 'orca computer perform-secondary-action --app --element-index --action [--window-id | --window-index ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [...COMPUTER_ACTION_FLAGS, 'element-index', 'action'] + }, + { + path: ['computer', 'scroll'], + summary: 'Scroll an app element or window coordinate', + usage: + 'orca computer scroll --app (--element-index | --x --y ) --direction [--window-id | --window-index ] [--pages ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [...COMPUTER_ACTION_FLAGS, 'element-index', 'x', 'y', 'direction', 'pages'] + }, + { + path: ['computer', 'drag'], + summary: 'Drag between app elements or window coordinates', + usage: + 'orca computer drag --app (--from-element-index --to-element-index | --from-x --from-y --to-x --to-y ) [--window-id | --window-index ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [ + ...COMPUTER_ACTION_FLAGS, + 'from-element-index', + 'to-element-index', + 'from-x', + 'from-y', + 'to-x', + 'to-y' + ] + }, + { + path: ['computer', 'type-text'], + summary: 'Type literal text at the current app focus', + usage: + 'orca computer type-text --app (--text | --text-stdin) [--window-id | --window-index ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [...COMPUTER_ACTION_FLAGS, 'text', 'text-stdin'] + }, + { + path: ['computer', 'press-key'], + summary: 'Press a key using xdotool-style syntax', + usage: + 'orca computer press-key --app --key [--window-id | --window-index ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [...COMPUTER_ACTION_FLAGS, 'key'] + }, + { + path: ['computer', 'hotkey'], + summary: 'Press a platform-aware key combination', + usage: + 'orca computer hotkey --app --key [--window-id | --window-index ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [...COMPUTER_ACTION_FLAGS, 'key'] + }, + { + path: ['computer', 'paste-text'], + summary: 'Paste exact text at the current app focus', + usage: + 'orca computer paste-text --app (--text | --text-stdin) [--window-id | --window-index ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [...COMPUTER_ACTION_FLAGS, 'text', 'text-stdin'] + }, + { + path: ['computer', 'set-value'], + summary: 'Set the value of a settable app element', + usage: + 'orca computer set-value --app --element-index (--value | --value-stdin) [--window-id | --window-index ] [--restore-window] [--no-screenshot] [--json]', + allowedFlags: [...COMPUTER_ACTION_FLAGS, 'element-index', 'value', 'value-stdin'] + } +] diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index ca40b6bd846..b85424e8ac0 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -72,11 +72,22 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment', 'run-hooks'], + 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'repo', + 'name', + 'base-branch', + 'issue', + 'comment', + 'run-hooks', + 'activate' + ], notes: [ - 'By default this matches the Orca UI flow and activates the new worktree in the app.', - 'Repo-defined orca.yaml hooks are skipped unless --run-hooks is passed.' + 'By default this creates the worktree and its first terminal without switching the active Orca workspace.', + 'Repo-defined setup hooks follow the repository setup policy; pass --run-hooks to force them.', + 'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.', + 'Passing --run-hooks reveals the worktree so the setup hook can run in its first terminal.' ] }, { @@ -148,13 +159,17 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['terminal', 'create'], - summary: 'Create a new terminal tab in the current worktree', + summary: 'Create a terminal session in the current worktree', usage: - 'orca terminal create [--worktree ] [--title ] [--command ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title'], + 'orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title', 'focus'], + notes: [ + 'Creates a visible terminal tab without switching focus when possible; falls back to a background handle if the UI cannot adopt it. Pass --focus to switch to it.' + ], examples: [ 'orca terminal create --json', - 'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"' + 'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"', + 'orca terminal create --worktree path:/projects/myapp --command "opencode" --focus' ] }, { diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index a29a48b6abf..dad04e7cc6f 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -3,10 +3,12 @@ import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { CORE_COMMAND_SPECS } from './core' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' +import { COMPUTER_COMMAND_SPECS } from './computer' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, ...BROWSER_ADVANCED_COMMAND_SPECS, - ...ORCHESTRATION_COMMAND_SPECS + ...ORCHESTRATION_COMMAND_SPECS, + ...COMPUTER_COMMAND_SPECS ] diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index e527fe8b953..ac79fd00f92 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -23,8 +23,23 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'check'], summary: 'Check messages for a terminal', usage: - 'orca orchestration check [--terminal ] [--unread] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'unread', 'types', 'inject', 'wait', 'timeout-ms'] + 'orca orchestration check [--terminal ] [--unread | --all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\n' + + ' --unread (default): return only unread messages and mark them read.\n' + + ' --all: return every message for the handle; does not mark read.\n' + + ' --wait: block until a matching message arrives or --timeout-ms expires.\n' + + ' Emits JSON heartbeat lines to stderr every 15s so the caller can\n' + + ' tell the process is alive. Filter with `grep -v _heartbeat` or\n' + + ' `jq "select(._heartbeat|not)"` when merging streams with 2>&1.', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'terminal', + 'unread', + 'all', + 'types', + 'inject', + 'wait', + 'timeout-ms' + ] }, { path: ['orchestration', 'reply'], @@ -34,9 +49,9 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'inbox'], - summary: 'Show all messages across recipients', - usage: 'orca orchestration inbox [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit'] + summary: 'Show messages across (or for) recipients', + usage: 'orca orchestration inbox [--limit ] [--terminal ] [--full] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'terminal', 'full'] }, { path: ['orchestration', 'task-create'], @@ -56,20 +71,29 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Update a task status', usage: 'orca orchestration task-update --id --status [--result ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result'] + allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result'], + notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.'] }, { path: ['orchestration', 'dispatch'], summary: 'Dispatch a task to a terminal', usage: - 'orca orchestration dispatch --task --to [--from ] [--inject] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject'] + 'orca orchestration dispatch --task --to [--from ] [--inject] [--dry-run] [--return-preamble] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject', 'dry-run', 'return-preamble'] }, { path: ['orchestration', 'dispatch-show'], summary: 'Show dispatch context for a task', - usage: 'orca orchestration dispatch-show --task [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task'] + usage: + 'orca orchestration dispatch-show --task [--preamble] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'] + }, + { + path: ['orchestration', 'ask'], + summary: 'Ask the coordinator a question and block until answered', + usage: + 'orca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'to', 'question', 'options', 'timeout-ms', 'from'] }, { path: ['orchestration', 'run'], diff --git a/src/main/agent-hooks/install-telemetry.test.ts b/src/main/agent-hooks/install-telemetry.test.ts new file mode 100644 index 00000000000..9af8a321d4f --- /dev/null +++ b/src/main/agent-hooks/install-telemetry.test.ts @@ -0,0 +1,155 @@ +// Pins the contract between `runManagedHookInstallers` and the +// `agent_hook_install_failed` telemetry event: each catch must fire `track` +// with the correct agent label and a truncated error_message, and one +// installer's failure must not stop the others (fail-open semantics). + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() })) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) + +import { runManagedHookInstallers } from './install-telemetry' + +describe('runManagedHookInstallers', () => { + beforeEach(() => { + trackMock.mockReset() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('runs every installer when none throw and never calls track', () => { + const claude = vi.fn() + const codex = vi.fn() + runManagedHookInstallers([ + ['claude', claude], + ['codex', codex] + ]) + expect(claude).toHaveBeenCalledTimes(1) + expect(codex).toHaveBeenCalledTimes(1) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('fires agent_hook_install_failed with the correct agent label when an installer throws', () => { + runManagedHookInstallers([ + [ + 'codex', + () => { + throw new Error('codex config malformed') + } + ] + ]) + + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', { + agent: 'codex', + error_message: 'codex config malformed' + }) + }) + + it('continues running later installers after an earlier one throws (fail-open)', () => { + const codex = vi.fn() + const gemini = vi.fn() + runManagedHookInstallers([ + [ + 'claude', + () => { + throw new Error('claude failed') + } + ], + ['codex', codex], + ['gemini', gemini] + ]) + expect(codex).toHaveBeenCalledTimes(1) + expect(gemini).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith( + 'agent_hook_install_failed', + expect.objectContaining({ agent: 'claude' }) + ) + }) + + it('truncates error_message to 200 chars', () => { + const longMessage = 'x'.repeat(500) + runManagedHookInstallers([ + [ + 'gemini', + () => { + throw new Error(longMessage) + } + ] + ]) + expect(trackMock).toHaveBeenCalledTimes(1) + const [, props] = trackMock.mock.calls[0] as [string, { error_message: string }] + expect(props.error_message.length).toBe(200) + }) + + it('handles non-Error throws', () => { + runManagedHookInstallers([ + [ + 'cursor', + () => { + throw 'cursor string failure' + } + ] + ]) + expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', { + agent: 'cursor', + error_message: 'cursor string failure' + }) + }) + + it('serializes thrown objects through JSON.stringify', () => { + runManagedHookInstallers([ + [ + 'cursor', + () => { + throw { code: 'EACCES', path: '/tmp' } + } + ] + ]) + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', { + agent: 'cursor', + error_message: '{"code":"EACCES","path":"/tmp"}' + }) + }) + + it('does not throw when an installer throws undefined (regression for JSON.stringify undefined return)', () => { + expect(() => + runManagedHookInstallers([ + [ + 'cursor', + () => { + throw undefined + } + ] + ]) + ).not.toThrow() + expect(trackMock).toHaveBeenCalledTimes(1) + const [eventName, props] = trackMock.mock.calls[0] as [string, { error_message: string }] + expect(eventName).toBe('agent_hook_install_failed') + expect(typeof props.error_message).toBe('string') + }) + + it('continues running later installers when track itself throws (telemetry must not break fail-open)', () => { + const codex = vi.fn() + trackMock.mockImplementationOnce(() => { + throw new Error('telemetry blew up') + }) + expect(() => + runManagedHookInstallers([ + [ + 'claude', + () => { + throw new Error('claude failed') + } + ], + ['codex', codex] + ]) + ).not.toThrow() + expect(codex).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/agent-hooks/install-telemetry.ts b/src/main/agent-hooks/install-telemetry.ts new file mode 100644 index 00000000000..658872f7039 --- /dev/null +++ b/src/main/agent-hooks/install-telemetry.ts @@ -0,0 +1,53 @@ +// Per-agent managed-hook installer with fail-open semantics and PostHog +// attribution. Lifted out of `src/main/index.ts` so the loop is unit-testable +// without standing up the full Electron startup graph — the catch site needs +// the agent label to fire `agent_hook_install_failed`, and the previous +// closure-style loop lost it. + +import type { HookInstallAgent } from '../../shared/telemetry-events' +import { track } from '../telemetry/client' + +// Why: install errors are about config-file shape (malformed JSON, ACL +// denial), not user content — but messages can include paths or stack +// fragments. The 200-char cap matches `agentHookInstallFailedSchema.error_message` +// in `src/shared/telemetry-events.ts`; the validator drops overlength values, +// so truncation must happen here at the call site. +const ERROR_MESSAGE_MAX_LEN = 200 + +export type ManagedHookInstaller = readonly [HookInstallAgent, () => void] + +function describeError(error: unknown): string { + if (error instanceof Error) { + return error.message + } + if (typeof error === 'string') { + return error + } + try { + const json = JSON.stringify(error) + return typeof json === 'string' ? json : String(error) + } catch { + return String(error) + } +} + +export function runManagedHookInstallers(installers: readonly ManagedHookInstaller[]): void { + for (const [agent, install] of installers) { + try { + install() + } catch (error) { + console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error) + // Why: telemetry must not break fail-open. A throw inside `track` (e.g. + // a corrupted settings store the resolveConsent path reads from) would + // otherwise abort the for-loop and skip later agents' installers. + try { + track('agent_hook_install_failed', { + agent, + error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN) + }) + } catch (telemetryError) { + console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError) + } + } + } +} diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 906bceec451..a074db7403e 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -6,11 +6,18 @@ import { readFileSync, readdirSync, rmSync, - writeFileSync + writeFileSync, + chmodSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { createManagedCommandMatcher, writeHooksJson, type HooksConfig } from './installer-utils' +import { spawnSync } from 'child_process' +import { + createManagedCommandMatcher, + wrapPosixHookCommand, + writeHooksJson, + type HooksConfig +} from './installer-utils' let tmpDir: string let configPath: string @@ -141,4 +148,66 @@ describe('createManagedCommandMatcher', () => { it('does not match hooks for a different agent', () => { expect(match('/bin/sh "/path/agent-hooks/gemini-hook.sh"')).toBe(false) }) + + it('matches the guarded launcher form so wrapped commands sweep correctly', () => { + // Why: wrapPosixHookCommand wraps the launcher in `if [ -x ... ]; then ...; fi` + // so a stale entry no-ops instead of returning exit 127. The sweep on + // install() must still recognize the guarded form as managed, otherwise + // repeated installs would accumulate one guarded + one unguarded entry. + expect( + match( + 'if [ -x "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh" ]; then /bin/sh "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh"; fi' + ) + ).toBe(true) + }) +}) + +describe('wrapPosixHookCommand', () => { + it('produces a guarded command that no-ops when the script is missing', () => { + const cmd = wrapPosixHookCommand('/does/not/exist.sh') + expect(cmd).toBe("if [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; fi") + }) + + it('preserves spaces in the script path (Library/Application Support case)', () => { + // Why: Electron's userData on macOS lives under "Application Support" with + // a space. The guard must keep the path quoted so `[ -x ]` and `/bin/sh` + // each see one argument. + const cmd = wrapPosixHookCommand('/Users/a/Library/Application Support/Orca/agent-hooks/x.sh') + expect(cmd).toContain("'/Users/a/Library/Application Support/Orca/agent-hooks/x.sh'") + }) + + it('escapes embedded single quotes so the wrapped command stays well-formed', () => { + // Why: POSIX single-quote escape renders ' as '\''. Verify a path with an + // embedded quote does not break out of the quoting and instead reaches + // /bin/sh as a single argument. + const cmd = wrapPosixHookCommand("/path/with'quote/x.sh") + expect(cmd).toBe( + "if [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; fi" + ) + }) + + it.skipIf(process.platform === 'win32')( + 'returns exit code 0 when the script does not exist (no-op)', + () => { + const cmd = wrapPosixHookCommand('/does/not/exist.sh') + const result = spawnSync('/bin/sh', ['-c', cmd]) + expect(result.status).toBe(0) + } + ) + + // Why: commit 4d618795 explicitly switched from `&& ... || true` (which + // swallowed non-zero exits) to `if ... then ... fi` (which preserves the + // script's exit code). This test guards against a future regression that + // re-introduces the swallowing form. + it.skipIf(process.platform === 'win32')( + 'propagates the script exit code when the script runs and fails', + () => { + const scriptPath = join(tmpDir, 'fails.sh') + writeFileSync(scriptPath, '#!/bin/sh\nexit 7\n', 'utf-8') + chmodSync(scriptPath, 0o755) + const cmd = wrapPosixHookCommand(scriptPath) + const result = spawnSync('/bin/sh', ['-c', cmd]) + expect(result.status).toBe(7) + } + ) }) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index eb1d028ebff..a6352850002 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -65,6 +65,22 @@ export function createManagedCommandMatcher( } } +// Why: a stale managed hook entry (left over after the user wiped userData, +// switched dev↔prod installs, or had a partial install fail) used to fire +// `/bin/sh ""` on every tool call, which exits 127 and surfaces +// as `PreToolUse hook (failed) error: hook exited with code 127` in the agent +// transcript. Wrapping the launcher in `if [ -x ... ]; then ...; fi` makes a +// missing/non-executable script a silent no-op so a broken install never +// poisons the user's session. Failures inside the script itself are +// unaffected — only the missing-script case short-circuits. +export function wrapPosixHookCommand(scriptPath: string): string { + // Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken + // literally — avoids a shell-injection footgun if a future caller passes an + // arbitrary path. + const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` + return `if [ -x ${quoted} ]; then /bin/sh ${quoted}; fi` +} + export function removeManagedCommands( definitions: HookDefinition[], isManagedCommand: (command: string | undefined) => boolean diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index d12c7375435..e59b4bbdc76 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -14,6 +14,7 @@ import { import { tmpdir } from 'os' import { join } from 'path' import { AgentHookServer, _internals } from './server' +import { parseAgentStatusPayload } from '../../shared/agent-status-types' const PANE = 'tab-1:0' @@ -73,16 +74,21 @@ describe('AgentHookServer listener replay', () => { server.setListener(listener) expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: expect.objectContaining({ - state: 'working', - prompt: 'replay me', - agentType: 'claude' + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload: expect.objectContaining({ + state: 'working', + prompt: 'replay me', + agentType: 'claude' + }) }) - }) + ) } finally { server.stop() } @@ -147,16 +153,21 @@ describe('AgentHookServer listener replay', () => { const listener = vi.fn() server.setListener(listener) - expect(listener).toHaveBeenCalledWith({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'repo::/tmp/worktree with "quotes"', - payload: expect.objectContaining({ - state: 'working', - prompt: 'form encoded', - agentType: 'claude' + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'repo::/tmp/worktree with "quotes"', + connectionId: null, + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload: expect.objectContaining({ + state: 'working', + prompt: 'form encoded', + agentType: 'claude' + }) }) - }) + ) } finally { server.stop() } @@ -660,6 +671,22 @@ describe('OpenCode hook normalization', () => { expect(result?.payload.state).toBe('waiting') }) + it('AskUserQuestion maps to waiting', () => { + // Why: OpenCode emits `question.asked` when the agent uses an ask-the-user + // tool (distinct from `permission.asked`, which blocks on tool approval). + // Both leave the agent idle-but-waiting on a human, so both must render + // the same red "needs attention" indicator. Without this mapping the pane + // silently stays in `working` and the user has no visual cue that the + // agent is waiting on them. + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'AskUserQuestion' }), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.agentType).toBe('opencode') + }) + it('unknown event name returns null', () => { const result = _internals.normalizeHookPayload( 'opencode', @@ -840,6 +867,154 @@ describe('Cursor hook normalization', () => { }) }) +describe('Pi hook normalization', () => { + it('before_agent_start maps to working and captures the prompt', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'rename this fn' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.agentType).toBe('pi') + expect(result?.payload.prompt).toBe('rename this fn') + }) + + it('agent_start without a prompt keeps the cached prompt from the current turn', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'first prompt' }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_start' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('first prompt') + }) + + it('before_agent_start clears the previous turn’s tool cache', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_call', + tool_name: 'bash', + tool_input: { command: 'ls' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'next' }), + 'production' + ) + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('tool_call surfaces tool_name + tool_input preview', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_call', + tool_name: 'bash', + tool_input: { command: 'pnpm test' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('bash') + expect(result?.payload.toolInput).toBe('pnpm test') + }) + + it('tool_execution_start also populates the tool preview', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_execution_start', + tool_name: 'read', + tool_input: { path: 'src/main/index.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('read') + expect(result?.payload.toolInput).toBe('src/main/index.ts') + }) + + it('message_end (assistant) stays in working but captures lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'message_end', + role: 'assistant', + text: 'Done — I refactored the helper.' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBe('Done — I refactored the helper.') + }) + + it('message_end (user) is ignored', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }), + 'production' + ) + // Why: pi captures the user prompt via before_agent_start, not via + // message_end. A user-role message_end should not flip lastAssistantMessage. + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + + it('agent_end maps to done', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_end' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.agentType).toBe('pi') + }) + + it('session_shutdown maps to done', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'session_shutdown' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + }) + + it('done preserves the cached lastAssistantMessage from a prior message_end', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'message_end', + role: 'assistant', + text: 'final reply' + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_end' }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('final reply') + }) + + it('unknown event names are dropped', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'never_heard_of_it' }), + 'production' + ) + expect(result).toBeNull() + }) +}) + describe('Endpoint file lifecycle', () => { let userDataPath: string @@ -1038,3 +1213,537 @@ describe('Endpoint file lifecycle', () => { } }) }) + +describe('Last-status persistence', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-laststatus-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function lastStatusPath(): string { + return join(userDataPath, 'agent-hooks', 'last-status.json') + } + + // Why: hydrate now drops entries older than 7d (HYDRATE_MAX_AGE_MS). Use + // a recent-but-not-Date.now() timestamp in fixtures so the tests assert + // hydration behavior rather than racing the wall clock. + function recentTs(offsetMs = 0): number { + return Date.now() - 60 * 60 * 1000 + offsetMs + } + + async function postHookEvent( + server: AgentHookServer, + body: Body, + path: string = '/hook/claude' + ): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(body) + }) + } + + it('writes last-status.json after a hook event', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' }) + ) + // Synchronous flush via stop() captures the trailing-debounced write. + server.flushStatusPersistSync() + expect(existsSync(lastStatusPath())).toBe(true) + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(file.version).toBe(2) + expect(file.entries[PANE]).toMatchObject({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload: expect.objectContaining({ state: 'working', prompt: 'persist me' }) + }) + } finally { + server.stop() + } + }) + + it('hydrates last-status.json into the cache before listener registration', async () => { + // Pre-populate the file directly to simulate a prior session. + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + const stateStartedAt = recentTs(-1000) + const fileContents = { + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt, + payload: { + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + } + } + } + } + writeFileSync(lastStatusPath(), JSON.stringify(fileContents), 'utf8') + + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt, + payload: expect.objectContaining({ + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + }) + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt, + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + }) + ]) + } finally { + server.stop() + } + }) + + it('treats a corrupt file as empty hydration without throwing', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync(lastStatusPath(), 'not-json{{', 'utf8') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + } finally { + server.stop() + warnSpy.mockRestore() + } + }) + + it('rejects a stale version mismatch on hydrate', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 1, + entries: { + [PANE]: { + paneKey: PANE, + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + payload: { state: 'done', prompt: 'old version', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('version mismatch')) + } finally { + server.stop() + warnSpy.mockRestore() + } + }) + + it('drops entries with malformed paneKeys but keeps valid ones', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + // Missing colon — drop. + 'no-colon': { + paneKey: 'no-colon', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + payload: { state: 'done', prompt: 'bad', agentType: 'claude' } + }, + // Embedded paneKey mismatch — drop. + [PANE]: { + paneKey: 'tab-x:99', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } + }, + // Valid. + 'tab-good:0': { + paneKey: 'tab-good:0', + tabId: 'tab-good', + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'done', prompt: 'survived', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: 'tab-good:0', + payload: expect.objectContaining({ prompt: 'survived' }) + }) + ) + } finally { + server.stop() + } + }) + + it('drops hydrate entries older than the TTL cutoff', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const eightDaysAgoMs = Date.now() - 8 * 24 * 60 * 60 * 1000 + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + // Stale — should be dropped. + 'tab-old:0': { + paneKey: 'tab-old:0', + tabId: 'tab-old', + receivedAt: eightDaysAgoMs, + stateStartedAt: eightDaysAgoMs - 1000, + payload: { state: 'done', prompt: 'old', agentType: 'claude' } + }, + // Recent — should survive. + 'tab-fresh:0': { + paneKey: 'tab-fresh:0', + tabId: 'tab-fresh', + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'done', prompt: 'fresh', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const snapshot = server.getStatusSnapshot() + expect(snapshot.map((e) => e.paneKey)).toEqual(['tab-fresh:0']) + } finally { + server.stop() + } + }) + + it('drops a hydrate entry whose tabId disagrees with the paneKey prefix', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + 'tab-A:0': { + paneKey: 'tab-A:0', + // Why: deliberately divergent — paneKey says tab-A, the entry + // claims tab-B. Sanitizer must drop rather than hydrate this + // inconsistent row. + tabId: 'tab-B', + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + expect(server.getStatusSnapshot()).toEqual([]) + } finally { + server.stop() + } + }) + + it('clearPaneState evicts the entry from the on-disk file', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'about to drop' }) + ) + server.flushStatusPersistSync() + let parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]).toBeTruthy() + + server.clearPaneState(PANE) + server.flushStatusPersistSync() + parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]).toBeUndefined() + } finally { + server.stop() + } + }) + + it('skips a write when the serialized contents are byte-identical to the previous write', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'first' }) + ) + server.flushStatusPersistSync() + const firstMtime = statSync(lastStatusPath()).mtimeMs + + // Why: a no-op clearPaneState on a paneKey not in the cache is a + // mutation site that should NOT trigger a redundant write. (clear was + // designed to bail when nothing was evicted.) + server.clearPaneState('non-existent:0') + server.flushStatusPersistSync() + // Touch back to the same mtime would let the test pass spuriously, so + // assert no rewrite happened by checking that mtime is unchanged after + // a forced sync flush. + const secondMtime = statSync(lastStatusPath()).mtimeMs + expect(secondMtime).toBe(firstMtime) + } finally { + server.stop() + } + }) + + it('stop() flushes pending debounced writes synchronously', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'flush me' }) + ) + // Note: do NOT call flushStatusPersistSync explicitly — let stop() do it. + } finally { + server.stop() + } + // Why: file written even though we never explicitly flushed before stop — + // stop() must synchronously drain the pending trailing-debounced timer. + expect(existsSync(lastStatusPath())).toBe(true) + const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me') + }) +}) + +describe('AgentHookServer ingestRemote', () => { + it('stamps connectionId and forwards a valid relay envelope to the listener', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1') + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload + }) + ) + }) + + it('drops envelopes whose payload state is not in AGENT_STATUS_STATES', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + // Why: bypass parseAgentStatusPayload (which itself rejects bad states) by + // constructing an obviously-invalid payload — `ingestRemote` is the trust + // boundary we're testing, not the parser. + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'nonsense', prompt: '', agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('drops envelopes whose paneKey exceeds MAX_PANE_KEY_LEN', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + // 201 chars — one past the listener's 200-char cap. + const oversized = 'a'.repeat(201) + server.ingestRemote( + { paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects empty connectionId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, '') + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects whitespace-only connectionId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, ' ') + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects non-string tabId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 123 as unknown as string, worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects empty paneKey after trim', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote({ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1') + expect(listener).not.toHaveBeenCalled() + }) + + it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => { + // Why: the relay normally normalizes the payload on the wire, but a buggy + // or malicious relay could forward an over-cap field. ingestRemote must + // re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH + // cap (200 chars) is enforced at the trust boundary. + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'x'.repeat(500), agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledTimes(1) + const event = listener.mock.calls[0][0] as { payload: { prompt: string } } + expect(event.payload.prompt.length).toBe(200) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index aee9c83e780..d0e0354d5c5 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,1076 +1,180 @@ -/* eslint-disable max-lines -- Why: the hook server owns the full HTTP ingest surface (routing, body parsing, per-CLI normalization, transcript scan, pane dispatch) in one place so the contract with Claude/Codex/Gemini hooks stays consistent and doesn't drift across files. */ +/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ +// Why: this module is the Orca-main-process adapter for the shared +// agent-hook listener pipeline (`src/shared/agent-hook-listener.ts`). The +// listener internals (request parsing, payload normalization, endpoint-file +// writing, validation) live in `shared/` so the relay can host the same +// pipeline on the remote without dragging Electron in. This file owns: +// - the loopback HTTP socket + bearer-token auth +// - the IPC fanout (setListener / lastStatusByPaneKey replay) +// - the `ingestRemote` entry point that bypasses HTTP for relay-forwarded +// events (see docs/design/agent-status-over-ssh.md §5) +// - the on-disk last-status cache (`last-status.json`) that survives +// Orca restart so retained dashboard rows reappear on relaunch import { createServer, type IncomingMessage, type ServerResponse } from 'http' import { randomUUID } from 'crypto' -import { - chmodSync, - closeSync, - mkdirSync, - openSync, - readdirSync, - readSync, - renameSync, - statSync, - unlinkSync, - writeFileSync -} from 'fs' +import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' import { join } from 'path' -import { - parseAgentStatusPayload, - type ParsedAgentStatusPayload -} from '../../shared/agent-status-types' + import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + getEndpointFileName, + HOOK_REQUEST_SLOWLORIS_MS, + MAX_PANE_KEY_LEN, + normalizeHookPayload, + parseFormEncodedBody, + readRequestBody, + resolveHookSource, + writeEndpointFile, + type AgentHookEventPayload, + type HookListenerState +} from '../../shared/agent-hook-listener' +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { + type AgentStatusIpcPayload, + normalizeAgentStatusPayload +} from '../../shared/agent-status-types' -// Why: Pi is intentionally absent. Pi has no shell-command hook surface — -// its extensibility is an in-process TypeScript extension API (pi.on(...) -// with events like turn_start/turn_end/tool_execution_start), not a -// settings.json hook block that we could install alongside the Claude/Codex/ -// Gemini ones. Wiring Pi would require shipping a bundled Pi extension -// that POSTs to this server; until we do that, Pi panes fall back to -// terminal-title heuristics like any uninstrumented CLI. -// -// OpenCode rides this server via a bundled plugin (see opencode/hook-service) -// that fetch()es /hook/opencode from inside the OpenCode process. Unlike -// Claude/Codex/Gemini, OpenCode's event names are in-process plugin events -// (session.status, session.idle, permission.asked) rather than settings.json -// hook names, so the plugin pre-maps them to our hook_event_name vocabulary -// before POSTing. See normalizeOpenCodeEvent below for the mapping. -// -// Cursor (cursor-agent) exposes a declarative hooks.json surface that is -// conceptually similar to Claude's settings.json hooks but uses camelCase -// event names (beforeSubmitPrompt, preToolUse, postToolUse, stop, etc.) per -// https://cursor.com/docs/hooks. See normalizeCursorEvent below. -type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' +export type { AgentHookSource } -type AgentHookEventPayload = { - paneKey: string - tabId?: string - worktreeId?: string - payload: ParsedAgentStatusPayload +// Why: server-process-only enrichment of the shared event payload. The shared +// listener emits `AgentHookEventPayload` (the bare event shape). For +// persistence and the dashboard's "did the agent transition since I last +// looked?" comparison, we attach `receivedAt` (when the latest event arrived +// for this pane) and `stateStartedAt` (when the current state first appeared). +// Stored in `state.lastStatusByPaneKey` via assignability — `AgentHookEventPayload` +// is the declared map value, and the extra fields ride along untouched because +// the shared module only writes/clears, never reads. +type EnrichedAgentHookEventPayload = AgentHookEventPayload & { + receivedAt: number + stateStartedAt: number } -// Why: only log a given version/env mismatch once per process so a stale hook -// script that fires on every keystroke doesn't flood the logs. -const warnedVersions = new Set() -const warnedEnvs = new Set() -// Why: cap the warning Sets so a buggy or malicious local client that varies -// its `version`/`env` fields per request cannot grow these Sets without bound -// for the process lifetime. Once saturated, we drop further warnings (and skip -// inserting the new key) — the diagnostic value of "warn once" is preserved -// for the common case while memory stays bounded against untrusted input. -const MAX_WARNED_KEYS = 32 -// Why: hook events can arrive while Orca is windowless (common on macOS when -// the user closes the window but leaves the app running). Retain the latest -// normalized status per pane so reopening the window can replay current agent -// state instead of showing nothing until the next hook event happens. -const lastStatusByPaneKey = new Map() +// Why: name of the on-disk cache that survives Orca restart. Lives next to +// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross- +// restart artifacts stay co-located. +const LAST_STATUS_FILE_NAME = 'last-status.json' -// Why: Claude documents `prompt` on UserPromptSubmit; other agents may use -// different field names. Probe a small allowlist so we can surface the real -// user prompt in the dashboard regardless of which agent is reporting. -function extractPromptText(hookPayload: Record): string { - const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message'] - for (const key of candidateKeys) { - const value = hookPayload[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } +// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch +// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a +// developer who upgrades from an in-flight branch sees an empty hydration +// once instead of partially-typed legacy entries. New file format; never +// shipped to users at v1. A mismatched version is treated as a corrupt file +// (silent empty hydration). +const LAST_STATUS_FILE_VERSION = 2 + +// Why: trailing-edge debounce so a burst of hook events from a multi-agent +// run produces one disk write instead of N. The latency budget matches other +// hook-server batching; quit-time uses flushStatusPersistSync() for the +// guaranteed final flush. +const STATUS_PERSIST_DEBOUNCE_MS = 250 + +// Why: bound the on-disk file's growth across many sessions. PTY-teardown +// eviction handles closed panes, but daemon-restored PTYs that never re-attach +// and crash-recovery paths where teardown never fires can leave entries +// pinned forever. 7 days matches the user-visible "still relevant?" horizon — +// older entries have almost certainly been resolved or abandoned and should +// not resurrect on hydrate. +const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +type LastStatusFile = { + version: number + entries: Record +} + +// Why: paneKey is `${tabId}:${paneId}` — exactly one ':' with non-empty +// segments on either side. Used both at write time (defensive) and at +// hydrate time (drop on mismatch). +export function isValidPaneKey(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0) { + return false } - // Why: OpenCode's plugin sends MessagePart events with { role, text }. When - // role === 'user', the text *is* the prompt — surface it so the dashboard - // shows the user's most recent input even though OpenCode has no dedicated - // UserPromptSubmit event we can hook into. - if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { - const trimmed = hookPayload.text.trim() - if (trimmed.length > 0) { - return hookPayload.text - } + const colon = value.indexOf(':') + if (colon <= 0 || colon === value.length - 1) { + return false } - return '' + // Why: exactly one colon. Anything weirder is corruption. + return !value.includes(':', colon + 1) } -function parseFormEncodedBody(body: string): Record { - const params = new URLSearchParams(body) - const parsed: Record = {} - for (const [key, value] of params.entries()) { - parsed[key] = value - } - return parsed -} - -function readRequestBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - let byteLength = 0 - let settled = false - req.on('data', (chunk: Buffer) => { - if (settled) { - return - } - // Why: check size in bytes (not UTF-16 code units) and stop accumulating - // after rejection so a malicious client cannot push memory past the - // advertised 1 MB cap. - if (byteLength + chunk.length > 1_000_000) { - settled = true - reject(new Error('payload too large')) - req.destroy() - return - } - byteLength += chunk.length - chunks.push(chunk) - }) - req.on('end', () => { - if (settled) { - return - } - settled = true - try { - // Why: decode once via Buffer.concat so multi-byte UTF-8 characters that - // straddle a chunk boundary are reassembled correctly. Per-chunk - // `.toString('utf8')` would corrupt emoji or non-ASCII inside assistant - // messages. - const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '' - const contentType = req.headers['content-type'] ?? '' - if (typeof contentType === 'string' && contentType.includes('application/json')) { - resolve(body ? JSON.parse(body) : {}) - return - } - if ( - typeof contentType === 'string' && - contentType.includes('application/x-www-form-urlencoded') - ) { - resolve(parseFormEncodedBody(body)) - return - } - // Why: existing managed scripts POST JSON and the updated Unix scripts - // POST form-encoded data. Default to JSON for unknown/missing content - // types so legacy callers that omit the header still behave as before. - resolve(body ? JSON.parse(body) : {}) - } catch (error) { - reject(error) - } - }) - req.on('error', (err) => { - if (settled) { - return - } - settled = true - reject(err) - }) - // Why: req.destroy() (called by the slowloris setTimeout in the route - // handler) emits 'close'/'aborted' but not 'end' or 'error'. Without this - // handler the promise would never settle and the chunk buffers would be - // retained for the process lifetime, letting a slow client that holds a - // valid token accumulate pending closures indefinitely. - req.on('close', () => { - if (settled) { - return - } - settled = true - reject(new Error('aborted')) - }) - }) -} - -// Why: only UserPromptSubmit carries the user's prompt. Subsequent events in -// the same turn (PostToolUse, PermissionRequest, Stop, …) arrive with no -// prompt, so we cache the last prompt per pane and reuse it until a new -// prompt arrives. The cache survives across `done` so the user can still see -// what finished; it's reset on the next UserPromptSubmit. -const lastPromptByPaneKey = new Map() - -function resolvePrompt( +function sanitizeHydratedEntry( paneKey: string, - promptText: string, - options?: { resetOnNewTurn?: boolean } -): string { - if (options?.resetOnNewTurn) { - // Why: some turn-boundary events (e.g. Codex SessionStart, OpenCode - // SessionBusy) do not carry the new prompt yet. Clearing here prevents the - // previous turn's prompt from leaking into the new working state until a - // later prompt-bearing event arrives. - lastPromptByPaneKey.delete(paneKey) + rawEntry: unknown +): EnrichedAgentHookEventPayload | null { + if (!isValidPaneKey(paneKey)) { + return null } - if (promptText) { - lastPromptByPaneKey.set(paneKey, promptText) - return promptText + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null } - return lastPromptByPaneKey.get(paneKey) ?? '' -} - -type ToolSnapshot = { - toolName?: string - toolInput?: string - lastAssistantMessage?: string -} - -// Why: mirrors `lastPromptByPaneKey`. Tool + assistant metadata arrives -// piecemeal (PreToolUse gives name+input; PostToolUse gives response; -// Stop gives the final message), and later events typically omit fields -// the earlier ones provided. Caching per-pane lets the renderer show a -// coherent snapshot instead of blinking whenever a field is missing. -const lastToolByPaneKey = new Map() - -function resolveToolState( - paneKey: string, - update: ToolSnapshot, - options: { resetOnNewTurn: boolean } -): ToolSnapshot { - if (options.resetOnNewTurn) { - // Why: a fresh user turn shouldn't inherit the previous turn's - // tool/assistant state — it would look like the agent is still on - // the old step until the first new tool event lands. - lastToolByPaneKey.delete(paneKey) + const record = rawEntry as Record + if (record.paneKey !== paneKey) { + return null } - const previous = lastToolByPaneKey.get(paneKey) ?? {} - const merged: ToolSnapshot = { - toolName: update.toolName ?? previous.toolName, - toolInput: update.toolInput ?? previous.toolInput, - lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage + const tabId = record.tabId + if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { + return null } - lastToolByPaneKey.set(paneKey, merged) - return merged -} - -// Why: per-tool allowlist (noqa style) — explicit mapping from tool name to -// the single input field worth surfacing. Tools that aren't listed render -// name-only. This avoids noisy fallbacks (e.g. "TaskUpdate 3" from the -// task_id field) and keeps the preview honest: if we don't know how to -// describe a tool's input meaningfully, we show nothing rather than guess. -// -// Ordering matters when a tool sends multiple well-known keys (e.g. Grep -// sends both `pattern` and `path`); the first match wins. -const TOOL_INPUT_KEYS_BY_TOOL: Record = { - // Claude tools (PascalCase). - Read: ['file_path', 'filePath', 'path'], - Write: ['file_path', 'filePath', 'path'], - Edit: ['file_path', 'filePath', 'path'], - MultiEdit: ['file_path', 'filePath', 'path'], - NotebookEdit: ['file_path', 'filePath', 'path'], - Bash: ['command'], - Glob: ['pattern'], - Grep: ['pattern'], - WebFetch: ['url'], - WebSearch: ['query'], - // Gemini tools (snake_case). - read_file: ['file_path', 'path'], - write_file: ['file_path', 'path'], - read_many_files: ['file_path', 'paths', 'path'], - edit_file: ['file_path', 'path'], - replace: ['file_path', 'path'], - run_shell_command: ['command'], - glob: ['pattern'], - search_file_content: ['pattern'], - web_fetch: ['url'], - google_web_search: ['query'], - // Codex tools. `exec_command` and `shell_command` both carry their command - // text under `cmd` (the Rust payload) or `command` (some wrappers); list - // both so whichever field is populated wins. `apply_patch` surfaces the - // touched path. `view_image` is path-only. `write_stdin` gets nothing - // meaningful — intentionally omitted so the row stays name-only. - exec_command: ['cmd', 'command'], - shell_command: ['cmd', 'command'], - apply_patch: ['path', 'file_path'], - view_image: ['path', 'file_path'] -} - -function deriveToolInputPreview( - toolName: string | undefined, - toolInput: unknown -): string | undefined { - if (typeof toolInput === 'string') { - return toolInput + // Why: paneKey is `${tabId}:${paneId}`; a stored entry whose tabId field + // diverges from the key's tab segment is corruption (renamer bug, manual + // edit, future shape drift). Drop instead of hydrating an inconsistent row. + if (typeof tabId === 'string' && tabId !== paneKey.slice(0, paneKey.indexOf(':'))) { + return null } - if (typeof toolInput !== 'object' || toolInput === null) { - return undefined + const worktreeId = record.worktreeId + if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { + return null } - if (!toolName) { - return undefined + const receivedAt = record.receivedAt + if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { + return null } - const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] - if (!keys) { - return undefined - } - const record = toolInput as Record - for (const key of keys) { - const value = record[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - return undefined -} - -function readString(record: Record, key: string): string | undefined { - const value = record[key] - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -// Why: Claude `tool_response` can be a string, or an object with a `content` -// array shaped like `[{type: 'text', text: '...'}]`. Surface the first text -// block so PostToolUse for Task/Agent subagents carries something useful into -// the `lastAssistantMessage` slot. -function extractToolResponseText(toolResponse: unknown): string | undefined { - if (typeof toolResponse === 'string' && toolResponse.length > 0) { - return toolResponse - } - if (typeof toolResponse !== 'object' || toolResponse === null) { - return undefined - } - const record = toolResponse as Record - const content = record.content - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - const text = record.text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - return undefined -} - -// Why: Claude's Stop event carries `transcript_path` to a JSONL transcript. -// Reading the last assistant message gives us the "what did the agent just -// say" preview without needing to buffer tool_response text across PostToolUse -// events. We scan backward from the end of the file in chunks, stopping as -// soon as we find an assistant text entry — bounded work in the common case -// (one chunk) even when transcripts grow to hundreds of MB. -const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 -// Why: ultimate safety cap so a malformed transcript (or a turn with -// pathologically many tool calls and no assistant text) cannot stall the Stop -// handler. 4 MB easily accommodates dozens of tool rounds before the final -// reply; past that, we give up rather than block the hook response. -const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 - -function extractAssistantTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = JSON.parse(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - const nestedMessage = record.message as Record | undefined - const role = record.role ?? nestedMessage?.role - if (role !== 'assistant') { - return undefined - } - const content = (nestedMessage ?? record).content - if (typeof content === 'string' && content.trim().length > 0) { - return content - } - // Why: assistant entries can be pure tool_use turns with no text parts. - // Return undefined so the caller keeps scanning backward for the most - // recent entry that actually contains assistant text. - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - return undefined -} - -function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - try { - const stats = statSync(transcriptPath) - const size = stats.size - if (size <= 0) { - return undefined - } - const fd = openSync(transcriptPath, 'r') - try { - // Why: track unhandled leading bytes as a raw Buffer across iterations - // so multi-byte UTF-8 codepoints that straddle chunk boundaries are not - // corrupted. A previous implementation decoded the combined chunk then - // re-encoded `lines[0]` back to UTF-8 for the carry; when a chunk - // started mid-codepoint the decode produced U+FFFD replacement chars - // and the re-encode baked those replacements into the carry bytes - // permanently, mis-joining every subsequent chunk. Splitting on \n at - // the byte level (0x0a) and only decoding complete-line regions keeps - // the carry byte-exact. - let carryBytes: Buffer = Buffer.alloc(0) - let bytesRead = 0 - while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES) - const position = size - bytesRead - chunkSize - const buffer = Buffer.alloc(chunkSize) - // Why: readSync may return fewer bytes than requested (short reads). - // Loop until the full window is read (or EOF) before processing so the - // backward scan windows stay aligned — a bare `bytesRead += n` would - // advance from the last read's tail and either re-read overlapping - // bytes on the next iteration or miss lines entirely if short reads - // accumulate. Rare on local regular files but fs quirks exist. - let filled = 0 - while (filled < chunkSize) { - const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) - if (n === 0) { - break - } - filled += n - } - const n = filled - bytesRead += n - if (n === 0) { - break - } - // Why: the newly-read chunk is earlier in the file than `carryBytes` - // (which came from the *previous* iteration's partial first line), - // so concatenation order is chunk first, carry second. - const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) - const atStart = bytesRead >= size - - // Find the first newline in the raw bytes. Everything before it is - // a (still) potentially-partial line when we haven't reached SOF; - // everything from it onward is a sequence of complete lines we can - // decode safely. - const firstNewline = combined.indexOf(0x0a) - let completeRegion: Buffer - let nextCarry: Buffer - if (atStart) { - // At start-of-file there is no earlier chunk; every line is complete. - completeRegion = combined - nextCarry = Buffer.alloc(0) - } else if (firstNewline === -1) { - // No newline in the combined bytes: the entire region is one - // potentially-partial line — carry all of it forward. - completeRegion = Buffer.alloc(0) - nextCarry = combined - } else { - // Bytes [0, firstNewline) are the partial-line carry; bytes - // [firstNewline+1, end) are the complete-line region. Dropping the - // newline itself avoids an empty leading "" line after split. - nextCarry = combined.subarray(0, firstNewline) - completeRegion = combined.subarray(firstNewline + 1) - } - - if (completeRegion.length > 0) { - const lines = completeRegion.toString('utf8').split('\n') - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim() - if (line.length === 0) { - continue - } - const extracted = extractAssistantTextFromLine(line) - if (extracted !== undefined) { - return extracted - } - } - } - carryBytes = nextCarry - } - return undefined - } finally { - closeSync(fd) - } - } catch { - return undefined - } -} - -function extractClaudeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - const update: ToolSnapshot = {} + const stateStartedAt = record.stateStartedAt if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' + typeof stateStartedAt !== 'number' || + !Number.isFinite(stateStartedAt) || + stateStartedAt <= 0 ) { - const toolName = readString(hookPayload, 'tool_name') - update.toolName = toolName - update.toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - } - if (eventName === 'PostToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_response) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'PostToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_response) ?? - readString(hookPayload, 'error') ?? - readString(hookPayload, 'message') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - if (eventName === 'Stop') { - // Why: newer Claude versions include `last_assistant_message` directly on - // the Stop payload, which is both cheaper and more reliable than reading - // the JSONL transcript. Prefer it when present; fall back to transcript - // scanning for older Claude versions that omit the field. - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - update.lastAssistantMessage = direct - } else { - const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) - if (lastFromTranscript) { - update.lastAssistantMessage = lastFromTranscript - } - } - } - return update -} - -function extractCodexToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { - // Why: Codex emits tool metadata under `tool_name` + `tool_input` - // (matching Claude's shape). We surface both so the dashboard row can - // show what the agent is currently doing during the otherwise-silent - // gap between UserPromptSubmit and Stop. See TOOL_INPUT_KEYS_BY_TOOL - // for which input field is previewed per Codex tool name. - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) - return { toolName, toolInput } - } - if (eventName === 'Stop') { - // Why: Codex documents `last_assistant_message` on Stop. - const message = readString(hookPayload, 'last_assistant_message') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractGeminiToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.args) ?? - deriveToolInputPreview(toolName, hookPayload.input) - return { toolName, toolInput } - } - if (eventName === 'AfterAgent') { - // Why: Gemini's AfterAgent payload carries the final reply under - // `prompt_response` (per geminicli.com/docs/hooks/reference). This is - // Gemini's analogue of Claude/Codex's `last_assistant_message` on Stop; - // surfacing it lets the dashboard show the agent's response on done. - const message = readString(hookPayload, 'prompt_response') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractOpenCodeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { - // Why: OpenCode streams the assistant's reply via repeated MessagePart - // events (one per text delta flush). Each event carries the cumulative - // text-so-far for that TextPart, so the latest one we see is the most - // complete snapshot to surface on `done`. We do NOT gate on SessionIdle - // because the plugin emits parts *before* session.idle fires, and gating - // would lose them. - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -// Why: Cursor's preToolUse / postToolUse / postToolUseFailure payloads carry -// `tool_name` + `tool_input` (same shape as Claude). beforeShellExecution / -// beforeMCPExecution carry a `command` field directly — surface that via a -// synthetic "Shell" / "MCP" tool name so the dashboard row can show the -// pending command while cursor-agent is blocked on approval. -// afterAgentResponse carries a `text` field that is cursor's analogue of -// Claude's last_assistant_message (the final composed reply for the turn). -function extractCursorToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' - ) { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - const update: ToolSnapshot = { toolName, toolInput } - if (eventName === 'postToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'postToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_output) ?? - readString(hookPayload, 'error_message') ?? - readString(hookPayload, 'error') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - return update - } - if (eventName === 'beforeShellExecution') { - const command = readString(hookPayload, 'command') - return { toolName: 'Shell', toolInput: command } - } - if (eventName === 'beforeMCPExecution') { - const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - readString(hookPayload, 'command') ?? - readString(hookPayload, 'url') - return { toolName, toolInput } - } - if (eventName === 'afterAgentResponse') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { - if (source === 'claude') { - return eventName === 'UserPromptSubmit' - } - if (source === 'codex') { - // Why: Codex fires SessionStart at resume AND startup. Both mark the - // boundary of a fresh interactive turn from the hook's perspective, so - // clear the tool cache on either one. - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' - } - if (source === 'gemini') { - return eventName === 'BeforeAgent' - } - if (source === 'cursor') { - // Why: Cursor's beforeSubmitPrompt is the new-turn boundary (it carries - // the fresh prompt). sessionStart also begins a fresh session and should - // not inherit any cached tool state from whatever was left on disk. - return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' - } - // Why: OpenCode has no UserPromptSubmit analogue, AND the plugin emits the - // user's MessagePart *before* SessionBusy (message.updated fires on prompt - // submission; session.status goes busy only once OpenCode begins processing). - // So resetting on SessionBusy would clobber the user prompt that was just - // cached. The role=user MessagePart itself naturally overwrites the cache - // with each new turn, so no separate reset is needed here. - return false -} - -function extractToolFields( - source: AgentHookSource, - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (source === 'claude') { - return extractClaudeToolFields(eventName, hookPayload) - } - if (source === 'codex') { - return extractCodexToolFields(eventName, hookPayload) - } - if (source === 'gemini') { - return extractGeminiToolFields(eventName, hookPayload) - } - if (source === 'cursor') { - return extractCursorToolFields(eventName, hookPayload) - } - return extractOpenCodeToolFields(eventName, hookPayload) -} - -function normalizeClaudeEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' - ? 'working' - : eventName === 'PermissionRequest' - ? 'waiting' - : eventName === 'Stop' - ? 'done' - : null - - if (!state) { return null } - - const snapshot = resolveToolState(paneKey, extractToolFields('claude', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('claude', eventName) - }) - - // Why: Claude Code's `Stop` hook sets `is_interrupt: true` when the turn - // ended because the user hit ESC / Ctrl+C rather than completing normally. - // This is the authoritative signal (the agent itself reports it), so we - // forward it through only on Stop — other hook events don't carry it. - const interrupted = - eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('claude', eventName) - }), - agentType: 'claude', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) - ) -} - -// Why: Gemini CLI exposes BeforeAgent/AfterAgent/AfterTool hooks. BeforeAgent -// fires at turn start and AfterTool resumes the working state after a tool -// call completes; AfterAgent fires when the agent becomes idle. Gemini has no -// permission-prompt hook, so we cannot surface a waiting state for Gemini. -function normalizeGeminiEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'BeforeAgent' || - eventName === 'AfterTool' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'AfterAgent' - ? 'done' - : null - - if (!state) { + // Why: connectionId is allowed to be null (local) or string (relay). Any + // other shape is rejected so the post-merge typed surface stays honest. + const connectionIdRaw = record.connectionId + let connectionId: string | null + if (connectionIdRaw === null || connectionIdRaw === undefined) { + connectionId = null + } else if (typeof connectionIdRaw === 'string') { + connectionId = connectionIdRaw + } else { return null } - - const snapshot = resolveToolState(paneKey, extractToolFields('gemini', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('gemini', eventName) - }) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('gemini', eventName) - }), - agentType: 'gemini', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -// Why: we deliberately do NOT map Codex `PreToolUse` to `waiting`. That event -// fires for every tool call, not just ones that actually need approval, so -// mapping it would flicker the dashboard. Instead we keep it at `working` -// (same as Claude) and use it only to update tool-name / tool-input previews -// so a running Codex turn has visible progress between UserPromptSubmit and -// Stop. Real approval signals travel through Codex's separate `notify` -// callback (different install surface); wiring that up is deferred. -function normalizeCodexEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'SessionStart' || - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'Stop' - ? 'done' - : null - - if (!state) { + const payload = normalizeAgentStatusPayload(record.payload) + if (!payload) { return null } - - const snapshot = resolveToolState(paneKey, extractToolFields('codex', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }), - agentType: 'codex', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -// Why: OpenCode has no declarative hook surface — it exposes in-process plugin -// events (session.status busy/idle, session.idle, permission.asked, -// message.updated, message.part.updated). The bundled plugin (see -// opencode/hook-service) pre-maps those to our stable hook_event_name -// vocabulary before POSTing so this normalizer can share the same switch -// shape as Claude/Codex/Gemini. SessionBusy = turn started, SessionIdle = -// turn finished, PermissionRequest = blocked on user approval, MessagePart = -// incremental text from user prompt or assistant reply (stays in `working` -// because streaming chunks must not flip the row to done mid-turn). -function normalizeOpenCodeEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'SessionBusy' || eventName === 'MessagePart' - ? 'working' - : eventName === 'SessionIdle' - ? 'done' - : eventName === 'PermissionRequest' - ? 'waiting' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState( + return { paneKey, - extractToolFields('opencode', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('opencode', eventName) } - ) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('opencode', eventName) - }), - agentType: 'opencode', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) + tabId: typeof tabId === 'string' ? tabId : undefined, + worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, + connectionId, + payload, + receivedAt, + stateStartedAt + } } -// Why: Cursor (cursor-agent) installs hooks via ~/.cursor/hooks.json with -// camelCase event names, per https://cursor.com/docs/hooks. The CLI fires -// stdin-JSON payloads for each subscribed event; we subscribe to the subset -// that marks turn boundaries and produces meaningful working/done/waiting -// transitions for the Orca sidebar. afterAgentResponse carries the final -// assistant reply text, which is cursor's analogue of Claude's Stop -// last_assistant_message — we keep the row in `working` there because the -// true turn-end signal is `stop`. -function normalizeCursorEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'beforeSubmitPrompt' || - eventName === 'sessionStart' || - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' || - eventName === 'afterAgentResponse' - ? 'working' - : eventName === 'stop' || eventName === 'sessionEnd' - ? 'done' - : eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution' - ? 'waiting' - : null - - if (!state) { - return null +function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { + return { + paneKey: entry.paneKey, + tabId: entry.tabId, + worktreeId: entry.worktreeId, + connectionId: entry.connectionId, + receivedAt: entry.receivedAt, + stateStartedAt: entry.stateStartedAt, + ...entry.payload } - - const snapshot = resolveToolState(paneKey, extractToolFields('cursor', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('cursor', eventName) - }) - - // Why: cursor-agent reports turn interrupts via `stop` with status !== - // "completed" (e.g. "cancelled", "stopped"). Forward the boolean so the - // sidebar can render the interrupted-turn treatment that Claude uses. - const interrupted = - eventName === 'stop' && - typeof hookPayload.status === 'string' && - hookPayload.status !== 'completed' - ? true - : undefined - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('cursor', eventName) - }), - agentType: 'cursor', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) - ) -} - -function readStringField(record: Record, key: string): string | undefined { - const value = record[key] - if (typeof value !== 'string') { - return undefined - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -function normalizeHookPayload( - source: AgentHookSource, - body: unknown, - expectedEnv: string -): AgentHookEventPayload | null { - if (typeof body !== 'object' || body === null) { - return null - } - - const record = body as Record - const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const rawPayload = record.payload - const hookPayload = - typeof rawPayload === 'string' - ? (() => { - try { - return JSON.parse(rawPayload) - } catch { - return null - } - })() - : rawPayload - // Why: paneKey comes from an authenticated-but-potentially-malicious local - // client; bound its size so pathological clients cannot blow up the - // per-pane caches (lastPromptByPaneKey / lastToolByPaneKey) with multi-MB - // keys. 200 chars is well above any legitimate `${tabId}:${paneId}` value. - const MAX_PANE_KEY_LEN = 200 - if ( - !paneKey || - paneKey.length > MAX_PANE_KEY_LEN || - typeof hookPayload !== 'object' || - hookPayload === null - ) { - return null - } - - // Why: scripts installed by an older app build may send a different shape. - // We accept the request (fail-open) but log once so stale installs are - // diagnosable instead of silently degrading. - const version = readStringField(record, 'version') - if ( - version && - version !== ORCA_HOOK_PROTOCOL_VERSION && - !warnedVersions.has(version) && - warnedVersions.size < MAX_WARNED_KEYS - ) { - warnedVersions.add(version) - console.warn( - `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + - 'Reinstall agent hooks from Settings to upgrade the managed script.' - ) - } - - // Why: detects dev-vs-prod cross-talk. A hook installed by a dev build but - // triggered inside a prod terminal (or vice versa) still points at whichever - // loopback port the shell env captured, so the *other* instance may receive - // it. Logging the mismatch lets a user know their terminals are wired to the - // wrong Orca. - const clientEnv = readStringField(record, 'env') - if (clientEnv && clientEnv !== expectedEnv) { - const key = `${clientEnv}->${expectedEnv}` - if (!warnedEnvs.has(key) && warnedEnvs.size < MAX_WARNED_KEYS) { - warnedEnvs.add(key) - console.warn( - `[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` + - 'Likely a stale terminal from another Orca install.' - ) - } - } - - const tabId = readStringField(record, 'tabId') - const worktreeId = readStringField(record, 'worktreeId') - - const eventName = (hookPayload as Record).hook_event_name - const promptText = extractPromptText(hookPayload as Record) - const hookPayloadRecord = hookPayload as Record - const payload = - source === 'claude' - ? normalizeClaudeEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'codex' - ? normalizeCodexEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'gemini' - ? normalizeGeminiEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'cursor' - ? normalizeCursorEvent(eventName, promptText, paneKey, hookPayloadRecord) - : normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord) - - return payload ? { paneKey, tabId, worktreeId, payload } : null -} - -// Why: the endpoint file lives under userData so each Orca install (dev vs. -// packaged) has its own path and the two cannot clobber each other. Using a -// per-platform extension (`.env` on POSIX, `.cmd` on Windows) lets the hook -// scripts source the file with their platform-native syntax (`.` on POSIX, -// `call` on Windows); the OpenCode plugin's regex accepts both shapes so no -// platform detection is needed inside the plugin source either. -function getEndpointFileName(): string { - return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' -} - -// Why: every value in the endpoint file is sourced as shell. Reject any -// value that contains shell/cmd metacharacters so a future field whose -// value is not shell-safe-by-construction cannot command-inject via the -// sourced file. Keep to a conservative allowlist of common printable -// chars plus hyphen/dot/slash/colon/underscore — sufficient for ports, -// UUIDs, version strings, and env names. -// Rejects empty values (`+` quantifier) as defense-in-depth for future -// callers — an empty sourced `KEY=` would silently clear the env var in -// the sourcing shell, masking whatever legitimate value was previously set. -function isShellSafeEndpointValue(value: string): boolean { - return /^[A-Za-z0-9._:/-]+$/.test(value) } export class AgentHookServer { @@ -1081,39 +185,157 @@ export class AgentHookServer { // the server can detect dev vs. prod cross-talk. Set at start() from the // caller's knowledge of whether this is a packaged build. private env = 'production' - private onAgentStatus: ((payload: AgentHookEventPayload) => void) | null = null + private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null // Why: directory that holds the on-disk endpoint file. Set via start()'s // `userDataPath` option so the class has no direct Electron dependency - // (keeps it mockable in the vitest node environment). When unset, we skip - // the endpoint-file write entirely — hooks still work via PTY env, just - // without survive-a-restart semantics. + // (keeps it mockable in the vitest node environment). private endpointDir: string | null = null private endpointFilePathCache: string | null = null - // Why: tracks whether writeEndpointFile() succeeded for the *current* - // start(). Without this flag, buildPtyEnv() would expose - // ORCA_AGENT_HOOK_ENDPOINT pointing at a path that may hold stale - // coordinates from a prior crashed instance — hook scripts would source - // those stale coords and silently post to a dead server. Gating the - // ENDPOINT env var on a successful write preserves the - // fail-open-to-fresh-env guarantee. private endpointFileWritten = false + // Why: per-instance caches (warn-once Sets, lastPrompt/lastTool/lastStatus + // by paneKey). Held on the instance instead of as module-level Maps so + // tests can spin up multiple servers without state cross-contamination. + private state: HookListenerState = createHookListenerState() + // Why: full path to the on-disk last-status cache. Set in start() from + // userDataPath. Null when the server runs without a userDataPath (e.g. + // tests that skip the userDataPath option) — in that case, persistence is + // a no-op and only in-memory replay applies. + private lastStatusFilePath: string | null = null + // Why: trailing-edge debounce timer. Captured per-instance so multiple + // server instances in the same process (tests) don't share state. + private statusPersistTimer: ReturnType | null = null + // Why: identity check — skip writes when the JSON-stringified contents + // exactly match the last successful disk write. Cheap protection against + // re-firing trailing timers when nothing changed. + private lastWrittenJson: string | null = null - setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void { + setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { this.onAgentStatus = listener if (!listener) { return } // Why: replay is best-effort per pane so one throwing listener call can't // starve subsequent panes from being replayed. - for (const payload of lastStatusByPaneKey.values()) { + for (const payload of this.state.lastStatusByPaneKey.values()) { try { - listener(payload) + // Why: cache values are stored as enriched payloads (with receivedAt / + // stateStartedAt). The map's declared element type from the shared + // listener is the bare AgentHookEventPayload because the shared module + // never reads from this map; only this class does, and only enriched + // values are ever inserted. + listener(payload as EnrichedAgentHookEventPayload) } catch (err) { console.error('[agent-hooks] replay listener threw', err) } } } + /** Snapshot of the current cached statuses, in the IPC-shaped form the + * renderer consumes. Used by the `agentStatus:getSnapshot` IPC after + * workspace tabs have hydrated, so the dashboard catches up on any + * hook events that fired during startup. */ + getStatusSnapshot(): AgentStatusIpcPayload[] { + return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => + toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) + ) + } + + private attachStatusTiming(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload { + const now = Date.now() + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const stateStartedAt = + previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now + return { + ...payload, + receivedAt: now, + stateStartedAt + } + } + + /** Ingest a payload that arrived over the relay JSON-RPC channel rather + * than the local HTTP server. `connectionId` is the SshChannelMultiplexer + * identity Orca holds (the wire envelope carries connectionId: null and + * Orca stamps the real value here). The relay pre-normalizes the inner + * payload via the shared listener module; we re-run the canonical + * normalizer here as a defense-in-depth check at the trust boundary + * before feeding the event into the same `onAgentStatus` fanout the HTTP + * path uses. See docs/design/agent-status-over-ssh.md §5. */ + ingestRemote( + envelope: { + paneKey: string + tabId?: string + worktreeId?: string + // Why: forwarded verbatim from the agent CLI POST body on the remote so + // the warn-once cross-build / dev-vs-prod diagnostics fire identically + // to the local HTTP path. Declared on the type now; consumed in PR2. + env?: string + version?: string + payload: unknown + }, + connectionId: string + ): void { + // Why: signature says non-empty, but the wire crosses a trust boundary — + // re-check at runtime (and trim) so a whitespace-only or empty + // connectionId can't poison caches. + if (typeof connectionId !== 'string') { + return + } + const trimmedConnectionId = connectionId.trim() + if (trimmedConnectionId.length === 0) { + return + } + if (!envelope || typeof envelope.paneKey !== 'string' || envelope.paneKey.length === 0) { + return + } + // Why: match the listener's HTTP path — `normalizeHookPayload` trims and + // length-caps paneKey before caching, so the cache key here must follow + // the same rule or remote-vs-local events for the same pane would diverge. + const paneKey = envelope.paneKey.trim() + if (paneKey.length === 0 || paneKey.length > MAX_PANE_KEY_LEN) { + return + } + if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { + return + } + if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { + return + } + // Why: mirror the HTTP path's `readStringField` behavior — trim and treat + // empty-after-trim as undefined rather than letting a literal "" leak + // into the event. + const tabId = + envelope.tabId !== undefined && envelope.tabId.trim().length > 0 + ? envelope.tabId.trim() + : undefined + const worktreeId = + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined + // Why: the relay is across a trust boundary; re-run the canonical + // normalizer on the inner payload so prompt/agentType/toolName/toolInput + // length caps, embedded-newline collapse, and the `interrupted`-only-on- + // done invariant are enforced here too. Returns null on malformed input + // (including invalid state), which subsumes the prior explicit state + // check. + const normalizedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!normalizedPayload) { + return + } + const event: AgentHookEventPayload = { + paneKey, + tabId, + worktreeId, + connectionId: trimmedConnectionId, + payload: normalizedPayload + } + const enriched = this.attachStatusTiming(event) + this.state.lastStatusByPaneKey.set(paneKey, enriched) + this.scheduleStatusPersist() + this.onAgentStatus?.(enriched) + } + async start(options?: { env?: string; userDataPath?: string }): Promise { if (this.server) { return @@ -1125,9 +347,18 @@ export class AgentHookServer { if (options?.userDataPath) { this.endpointDir = join(options.userDataPath, 'agent-hooks') this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) + this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME) } this.token = randomUUID() this.endpointFileWritten = false + this.lastWrittenJson = null + // Why: hydrate before binding the HTTP listener so any new hook POST + // (which goes through state.lastStatusByPaneKey.set) runs against an + // already-populated map. The renderer later pulls this map as a snapshot + // after workspace tabs are hydrated. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'POST') { res.writeHead(404) @@ -1144,38 +375,26 @@ export class AgentHookServer { // Why: bound request time so a slow/stalled client cannot hold a socket // open indefinitely (slowloris-style). The hook endpoints are local and // should complete in well under a second. - req.setTimeout(5000, () => { + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { req.destroy() }) try { const body = await readRequestBody(req) - // Why: match on pathname only so a future debugging addition of a - // query string or trailing slash from a hook sender does not silently - // 404 a valid, token-authenticated request. const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - const source: AgentHookSource | null = - pathname === '/hook/claude' - ? 'claude' - : pathname === '/hook/codex' - ? 'codex' - : pathname === '/hook/gemini' - ? 'gemini' - : pathname === '/hook/opencode' - ? 'opencode' - : pathname === '/hook/cursor' - ? 'cursor' - : null + const source = resolveHookSource(pathname) if (!source) { res.writeHead(404) res.end() return } - const payload = normalizeHookPayload(source, body, this.env) - if (payload) { - lastStatusByPaneKey.set(payload.paneKey, payload) - this.onAgentStatus?.(payload) + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (normalized) { + const enriched = this.attachStatusTiming(normalized) + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.onAgentStatus?.(enriched) } res.writeHead(204) @@ -1189,12 +408,9 @@ export class AgentHookServer { }) await new Promise((resolve, reject) => { - // Why: the startup error handler must only reject the start() promise for - // errors that happen before 'listening'. Without swapping it out on - // success, any later runtime error (e.g. EADDRINUSE during rebind, - // socket errors) would call reject() on an already-settled promise and, - // more importantly, leaving it as the only 'error' listener means node - // treats runtime errors as unhandled and crashes the main process. + // Why: swap the startup error handler on success so a later runtime + // error (e.g. EADDRINUSE during rebind, socket errors) doesn't reject + // an already-settled promise or crash the main process as unhandled. const onStartupError = (err: Error): void => { this.server?.off('listening', onListening) reject(err) @@ -1208,11 +424,7 @@ export class AgentHookServer { if (address && typeof address === 'object') { this.port = address.port } - // Why: the endpoint file is the core of the survives-Orca-restart - // design. Write it *after* we have a concrete port — hooks that source - // the file must see a usable coordinate set, not a stale one left over - // from a previous process (e.g. one that crashed before getting here). - this.writeEndpointFile() + this.maybeWriteEndpointFile() resolve() } this.server!.once('error', onStartupError) @@ -1221,6 +433,12 @@ export class AgentHookServer { } stop(): void { + // Why: flush any pending debounced write to disk BEFORE we clear the + // in-memory map. Quit-time state must be captured even if the trailing + // timer was scheduled but had not yet fired; otherwise a multi-agent + // run that ended its last hook event <250 ms before quit would lose + // that final delta on relaunch. + this.flushStatusPersistSync() this.server?.close() this.server = null this.port = 0 @@ -1228,36 +446,41 @@ export class AgentHookServer { this.env = 'production' this.onAgentStatus = null // Why: intentionally do NOT delete the endpoint file on stop(). A stale - // file points at a dead port, which matches the fail-open policy (hook - // POSTs silently fail → same as pre-endpoint-file behavior). Attempting to unlink - // introduces a TOCTOU race: a concurrent Orca instance sharing userData - // could rewrite the file between our token check and unlink, and we'd - // delete their live endpoint file. The next successful start() overwrites - // the file atomically; the tmp-file sweep inside writeEndpointFile() - // handles orphan hygiene. + // file points at a dead port, which matches the fail-open policy. Unlink + // would introduce a TOCTOU race vs. a concurrent Orca instance. this.endpointDir = null this.endpointFilePathCache = null this.endpointFileWritten = false - // Why: drop all per-pane cache entries on shutdown so a subsequent start() - // in the same process (e.g. during tests or a settings-driven restart) - // does not inherit stale prompt/tool state from the previous run. - lastPromptByPaneKey.clear() - lastToolByPaneKey.clear() - lastStatusByPaneKey.clear() - // Why: across stop()/start() cycles the warn-once Sets would otherwise - // suppress legitimate new warnings after a restart. - warnedVersions.clear() - warnedEnvs.clear() + this.lastStatusFilePath = null + this.lastWrittenJson = null + clearAllListenerCaches(this.state) + } + + /** Why: invoked from the renderer-driven agentStatus:drop IPC when a user + * dismisses a still-active pane's status row. We must NOT wipe + * lastPromptByPaneKey or lastToolByPaneKey here — the pane's agent may + * still be alive, and the next hook event would otherwise arrive with an + * empty prompt and missing tool snapshot until a fresh UserPromptSubmit + * lands. clearPaneState (which wipes all three caches) is the right shape + * only for PTY-teardown. */ + dropStatusEntry(paneKey: string): void { + if (!this.state.lastStatusByPaneKey.has(paneKey)) { + return + } + this.state.lastStatusByPaneKey.delete(paneKey) + this.scheduleStatusPersist() } clearPaneState(paneKey: string): void { - // Why: callers invoke this on PTY teardown so the per-pane caches do not - // accumulate entries for dead panes over the process lifetime. Without - // this, every closed pane leaves its prompt + tool snapshot pinned in - // memory for the life of the main process. - lastPromptByPaneKey.delete(paneKey) - lastToolByPaneKey.delete(paneKey) - lastStatusByPaneKey.delete(paneKey) + // Why: only schedule a write when we actually evicted a status entry — + // dropping prompt/tool caches for a pane that never produced a hook + // event does not change the on-disk file, and skipping the write avoids + // re-stat'ing on every dead-pane teardown. + const hadStatus = this.state.lastStatusByPaneKey.has(paneKey) + clearPaneCacheState(this.state, paneKey) + if (hadStatus) { + this.scheduleStatusPersist() + } } buildPtyEnv(): Record { @@ -1265,12 +488,6 @@ export class AgentHookServer { return {} } - // Why: ORCA_AGENT_HOOK_ENDPOINT is the key that lets a surviving PTY reach - // the *current* Orca after a restart. The other four variables are retained - // for back-compat so pre-endpoint-file hook scripts (which do not know to - // source the endpoint file) continue to work on freshly spawned PTYs, and - // so the current script can fall through to env if the file is - // missing/unreadable for any reason. const env: Record = { ORCA_AGENT_HOOK_PORT: String(this.port), ORCA_AGENT_HOOK_TOKEN: this.token, @@ -1283,132 +500,187 @@ export class AgentHookServer { return env } - // Why: exposed as a read-only getter so tests (and any future main-process - // caller that needs the path for diagnostics) do not have to reconstruct - // the path convention. get endpointFilePath(): string | null { return this.endpointFilePathCache } - // Why: writes the four coordinates atomically via a tmp-then-rename so a - // hook reading concurrently either sees the old file or the new one, never - // a half-written one. Fail-open: on EACCES / ENOSPC / etc. we log and move - // on — start() remains usable via PTY env for freshly-spawned PTYs. Only - // survivors lose the endpoint-file path, matching the hook-payload - // fail-open policy already enforced on the receiving end. - private writeEndpointFile(): void { + /** Test/diagnostic accessor for the on-disk last-status file path. */ + get lastStatusPath(): string | null { + return this.lastStatusFilePath + } + + private maybeWriteEndpointFile(): void { if (!this.endpointDir || !this.endpointFilePathCache) { return } - // Why: defensive reset — buildPtyEnv() must not see a stale `true` from - // a previous start() if this write fails before reaching the success - // assignment below. this.endpointFileWritten = false - const finalPath = this.endpointFilePathCache - // Why: unique-per-call tmp name (mirrors persistence.ts / installer-utils.ts); prevents cross-process collision if two writers race on the same endpoint dir. - const tmpPath = join(this.endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) - const prefix = process.platform === 'win32' ? 'set ' : '' - // Why: every value written here is sourced as shell (`. "$file"` on - // POSIX, `call "%file%"` on Windows) — the file format IS shell, not - // key=value data. The current four inputs are shell-safe by - // construction: PORT is a number from listen(), TOKEN is randomUUID() - // output (hex + dashes only), VERSION is a compile-time string - // constant, and ENV is a fixed 'production' / 'development' literal - // passed from index.ts. Any future change that relaxes these - // invariants (user-supplied env name, persisted token, arbitrary - // free-form field) MUST add escaping or a safe-character validator - // before the write — otherwise a value like `foo&malicious` on Windows - // would command-inject via `call`, and a newline in any value would - // corrupt the POSIX sourceable output. The isShellSafeEndpointValue - // check below enforces this contract at runtime. - const valuesToWrite: [string, string][] = [ - ['ORCA_AGENT_HOOK_PORT', String(this.port)], - ['ORCA_AGENT_HOOK_TOKEN', this.token], - ['ORCA_AGENT_HOOK_ENV', this.env], - ['ORCA_AGENT_HOOK_VERSION', ORCA_HOOK_PROTOCOL_VERSION] - ] - for (const [key, value] of valuesToWrite) { - if (!isShellSafeEndpointValue(value)) { - console.error( - `[agent-hooks] refusing to write endpoint file: ${key} contains ` + - 'characters unsafe for shell sourcing. Falling back to PTY env.' - ) - return + const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION + }) + this.endpointFileWritten = ok + } + + private hydrateLastStatusFromDisk(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: defensive — keeps hydrate idempotent against repeated start() + // calls; production callers always have an empty map here, but a future + // re-start path must not silently merge prior-session state. + this.state.lastStatusByPaneKey.clear() + let raw: string + try { + raw = readFileSync(this.lastStatusFilePath, 'utf8') + } catch (err) { + // Why: missing file is the common case (first launch). + // Other errors (EACCES, etc.) degrade to empty hydration with a single + // warn so the dashboard renders normally. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[agent-hooks] failed to read last-status file:', err) + } + return + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') + return + } + if (typeof parsed !== 'object' || parsed === null) { + console.warn('[agent-hooks] last-status file is not an object; ignoring') + return + } + const file = parsed as Partial + if (file.version !== LAST_STATUS_FILE_VERSION) { + console.warn( + `[agent-hooks] last-status file version mismatch (${String( + file.version + )} != ${LAST_STATUS_FILE_VERSION}); ignoring` + ) + return + } + const entries = file.entries + if (typeof entries !== 'object' || entries === null) { + console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') + return + } + let hydrated = 0 + let dropped = 0 + // Why: bound disk growth — drop anything older than HYDRATE_MAX_AGE_MS so + // entries from worktrees archived weeks ago do not pile up forever. Use + // Date.now() once to keep the cutoff consistent across all entries this + // tick. + const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS + for (const [paneKey, rawEntry] of Object.entries(entries)) { + const entry = sanitizeHydratedEntry(paneKey, rawEntry) + if (entry && entry.receivedAt >= ttlCutoff) { + this.state.lastStatusByPaneKey.set(paneKey, entry) + hydrated += 1 + } else { + dropped += 1 } } - const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] + if (dropped > 0) { + console.warn( + `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` + ) + } + if (hydrated > 0 && dropped === 0) { + // Why: prime from the raw on-disk bytes (not a re-serialization) so the + // dedup is robust against future shape drift in serializeStatusFile. + // Only prime when hydration was lossless — if entries were dropped + // during sanitize, the in-memory map diverges from the on-disk bytes. + this.lastWrittenJson = raw + } else if (dropped > 0) { + // Why: clean the stale on-disk file now so a user who has not run an + // agent in 8+ days does not re-drop the same entries on every cold + // boot. Synchronous variant is safe at start time and avoids + // unref'd-timer-during-quit edge cases. + this.runStatusPersist() + } + } + + private serializeStatusFile(): string { + const entries: Record = {} + for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { + // Why: defensive — never persist invalid keys even if they slipped + // into the in-memory map somehow. Same invariant the hydrate path + // enforces. + if (!isValidPaneKey(paneKey)) { + continue + } + entries[paneKey] = payload as EnrichedAgentHookEventPayload + } + const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries } + return JSON.stringify(file) + } + + private scheduleStatusPersist(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: each call resets the timer; the disk write fires + // STATUS_PERSIST_DEBOUNCE_MS after the LAST event in the burst. + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + } + this.statusPersistTimer = setTimeout(() => { + this.statusPersistTimer = null + this.runStatusPersist() + }, STATUS_PERSIST_DEBOUNCE_MS) + // Why: don't keep the event loop alive just for a status flush — quit + // already triggers flushStatusPersistSync(). On Node 12+ unref() is a + // no-op when called on an already-unref'd timer. + if (typeof this.statusPersistTimer.unref === 'function') { + this.statusPersistTimer.unref() + } + } + + flushStatusPersistSync(): void { + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + this.statusPersistTimer = null + } + if (!this.lastStatusFilePath) { + return + } + this.runStatusPersist() + } + + private runStatusPersist(): void { + if (!this.lastStatusFilePath || !this.endpointDir) { + return + } + const json = this.serializeStatusFile() + if (json === this.lastWrittenJson) { + return + } + const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) let tmpWritten = false try { - // Why: mode 0o700 — match the file's owner-only policy so the - // agent-hooks/ directory itself does not leak the existence of this - // Orca install (or the presence of the endpoint file) to other local - // users on a multi-user POSIX host. Default umask would otherwise - // leave the dir at 0o755 even though the file inside is 0o600. mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) if (process.platform !== 'win32') { - // Why: mkdirSync's `mode` only applies when the dir is newly created — - // a pre-existing agent-hooks/ dir (from an earlier build or user - // intervention) keeps its original permissions. Re-chmod on every - // start() so the directory matches the 0600 file inside it. POSIX - // only; chmod semantics differ on Windows and the filesystem-level - // ACL model makes this check meaningless there. try { chmodSync(this.endpointDir, 0o700) } catch { - // Why: best-effort — a chmod failure (exotic fs, read-only mount) - // must not block the endpoint-file write itself. + // best-effort } } - // Why: a crash between writeFileSync and renameSync leaves stale - // `.endpoint--.tmp` in this directory. Sweep older-than-5-min - // orphans so the dir does not grow unboundedly. Fresh tmps are left - // alone so a legitimate concurrent instance is not disturbed. - try { - const entries = readdirSync(this.endpointDir) - const cutoff = Date.now() - 5 * 60 * 1000 - for (const entry of entries) { - if (!entry.startsWith('.endpoint-') || !entry.endsWith('.tmp')) { - continue - } - const entryPath = join(this.endpointDir, entry) - try { - if (statSync(entryPath).mtimeMs < cutoff) { - unlinkSync(entryPath) - } - } catch { - // best-effort sweep - } - } - } catch { - // readdirSync can fail on exotic filesystems; never block the write - } - // Why: 0o600 — the token is a loopback bearer credential and must not - // be readable by other local users. Parity with PTY env exposure via - // /proc//environ (owner-only on modern Linux). - // Why: `.cmd` files require CRLF for consistent `set` parsing across - // Windows versions — LF-only terminators are silently mis-parsed by - // some cmd.exe versions, which would break hook coord refresh (exactly - // the bug this file exists to fix). POSIX stays LF. - const separator = process.platform === 'win32' ? '\r\n' : '\n' - writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) + writeFileSync(tmpPath, json, { mode: 0o600 }) tmpWritten = true - renameSync(tmpPath, finalPath) - this.endpointFileWritten = true + renameSync(tmpPath, this.lastStatusFilePath) + this.lastWrittenJson = json } catch (err) { - console.error('[agent-hooks] failed to write endpoint file:', err) - // Why: clean up tmp; never nuke the prior finalPath when we cannot - // guarantee we have replaced it. Stale finalPath → dead port → silent - // fail on hook POST matches the fail-open policy documented on the - // receiver side. Destroying the prior file would strand surviving PTYs - // that *could* have continued to fail silently against a dead port — - // strictly worse than leaving the prior coords in place until the next - // successful start() overwrites them. + console.warn('[agent-hooks] failed to write last-status file:', err) if (tmpWritten) { try { unlinkSync(tmpPath) } catch { - // Why: tmp may already be gone (rename partially succeeded, or an - // external process cleaned it). Nothing to do. + // tmp already gone } } } @@ -1417,20 +689,28 @@ export class AgentHookServer { export const agentHookServer = new AgentHookServer() -// Why: exported for test coverage of the per-agent field extractors. The -// `normalizeHookPayload` function wraps these with the cache + routing logic -// the tests need to exercise end-to-end; making it test-visible avoids -// having to spin up a real HTTP server just to assert field shaping. +// Why: exported for test coverage of the per-agent field extractors. export const _internals = { - normalizeHookPayload, + // Why: bind the test-helper to the singleton's state so existing tests keep + // exercising the same caches the live server uses. + normalizeHookPayload: ( + source: AgentHookSource, + body: unknown, + expectedEnv: string + ): AgentHookEventPayload | null => + normalizeHookPayload(_singletonState(), source, body, expectedEnv), parseFormEncodedBody, resetCachesForTests: (): void => { - lastPromptByPaneKey.clear() - lastToolByPaneKey.clear() - lastStatusByPaneKey.clear() - // Why: across test runs the warn-once Sets would otherwise suppress - // legitimate new warnings that a later test expects to observe. - warnedVersions.clear() - warnedEnvs.clear() + clearAllListenerCaches(_singletonState()) } } + +// Why: ergonomic accessor so the `_internals` shim can reach the singleton's +// per-instance state without exposing `state` on the public class surface. +function _singletonState(): HookListenerState { + // The runtime field is private, but tests access this module exclusively + // through `_internals`, which only fires after the module-level + // `agentHookServer` is constructed. The cast keeps the compile-time + // private invariant intact. + return (agentHookServer as unknown as { state: HookListenerState }).state +} diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts new file mode 100644 index 00000000000..df328ad647d --- /dev/null +++ b/src/main/agent-trust-presets.test.ts @@ -0,0 +1,113 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const testState = { + fakeHomeDir: '' +} + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +const { markCopilotFolderTrusted, markCursorWorkspaceTrusted } = + await import('./agent-trust-presets') + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) +}) + +afterEach(() => { + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + testState.fakeHomeDir = '' +}) + +describe('markCursorWorkspaceTrusted', () => { + it('writes ~/.cursor/projects//.workspace-trusted with the cwd payload', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-')) + try { + markCursorWorkspaceTrusted(workspace) + const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects') + const slugDirs = readdirSync(projectsDir) + expect(slugDirs.length).toBe(1) + const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted') + expect(existsSync(trustFile)).toBe(true) + const payload = JSON.parse(readFileSync(trustFile, 'utf-8')) + expect(payload.workspacePath).toBeTruthy() + expect(typeof payload.trustedAt).toBe('string') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('is idempotent — re-marking the same workspace does not overwrite trustedAt', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-')) + try { + markCursorWorkspaceTrusted(workspace) + const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects') + const slugDirs = readdirSync(projectsDir) + const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted') + const firstPayload = readFileSync(trustFile, 'utf-8') + markCursorWorkspaceTrusted(workspace) + const secondPayload = readFileSync(trustFile, 'utf-8') + expect(secondPayload).toBe(firstPayload) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) +}) + +describe('markCopilotFolderTrusted', () => { + it('appends the workspace to trustedFolders in ~/.copilot/config.json', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + try { + markCopilotFolderTrusted(workspace) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + expect(existsSync(configPath)).toBe(true) + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(Array.isArray(parsed.trustedFolders)).toBe(true) + expect(parsed.trustedFolders.length).toBe(1) + expect(typeof parsed.trustedFolders[0]).toBe('string') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('preserves existing config keys and dedups already-trusted folders', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const realpath = realpathSync(workspace) + try { + mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true }) + writeFileSync( + join(testState.fakeHomeDir, '.copilot', 'config.json'), + JSON.stringify({ + firstLaunchAt: '2026-01-01T00:00:00.000Z', + trustedFolders: [realpath] + }) + ) + markCopilotFolderTrusted(workspace) + const parsed = JSON.parse( + readFileSync(join(testState.fakeHomeDir, '.copilot', 'config.json'), 'utf-8') + ) + expect(parsed.firstLaunchAt).toBe('2026-01-01T00:00:00.000Z') + expect(parsed.trustedFolders).toHaveLength(1) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts new file mode 100644 index 00000000000..e6c87c52b3f --- /dev/null +++ b/src/main/agent-trust-presets.ts @@ -0,0 +1,115 @@ +import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { writeFileAtomically } from './codex-accounts/fs-utils' + +/** + * Pre-mark a workspace as trusted for cursor-agent / GitHub Copilot CLI so + * the agent's "Do you trust this folder?" menu does not fire on first launch. + * + * Why: Orca's "drop URL into agent input as a draft" flow injects the URL + * via bracketed-paste once the TUI is up. If the trust menu intercepts the + * keystrokes (each menu reads a single character or numbered option), the + * paste either selects an arbitrary option or quits the session. Pre-writing + * the same trust artifacts that the agent writes after the user accepts is + * the only documented bypass — both CLIs read these files at startup before + * showing the menu. + * + * Side note: a `--trust`-style CLI flag exists in cursor-agent but only + * applies in `--print/headless` mode (per its --help). Copilot has no + * documented flag at all (verified against @github/copilot 1.0.32 bundle). + */ + +/** + * Cursor's CLI keeps a per-workspace trust marker at: + * ~/.cursor/projects//.workspace-trusted + * where is the absolute path with the leading `/` stripped and + * remaining `/` replaced with `-`. The file payload is `{ trustedAt, + * workspacePath }`. Verified against the cursor-agent CLI bundle + * (versions/2026.04.17-787b533/index.ts: `_=".workspace-trusted"`, slug + * derived via the same util that resolves `~/.cursor/projects/`). + */ +export function markCursorWorkspaceTrusted(workspacePath: string): void { + const absPath = canonicalize(workspacePath) + const slug = cursorWorkspaceSlug(absPath) + if (!slug) { + return + } + const trustDir = join(homedir(), '.cursor', 'projects', slug) + const trustFile = join(trustDir, '.workspace-trusted') + if (existsSync(trustFile)) { + return + } + mkdirSync(trustDir, { recursive: true }) + const payload = JSON.stringify( + { trustedAt: new Date().toISOString(), workspacePath: absPath }, + null, + 2 + ) + writeFileAtomically(trustFile, `${payload}\n`) +} + +/** + * GitHub Copilot CLI keeps a global list of trusted folders in + * ~/.copilot/config.json under `trustedFolders` (verified against the + * @github/copilot 1.0.32 bundle: `addTrustedFolder` and `isFolderTrusted` + * both read/write this exact key, and folder comparison is done after a + * realpath() resolution). + * + * We append to the array in-place so unrelated config keys (loggedInUsers, + * copilotTokens, etc.) survive untouched. + */ +export function markCopilotFolderTrusted(workspacePath: string): void { + const absPath = canonicalize(workspacePath) + const configDir = join(homedir(), '.copilot') + const configPath = join(configDir, 'config.json') + let config: Record = {} + try { + if (existsSync(configPath)) { + const raw = readFileSync(configPath, 'utf-8') + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') { + config = parsed as Record + } + } + } catch { + // Why: a corrupted config.json is the user's to fix — refuse to overwrite + // it from this side-effect path. Copilot will rewrite the file itself + // after the user accepts the trust prompt manually. + return + } + const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : [] + const normalizedExisting = existing.map((entry) => + typeof entry === 'string' ? canonicalize(entry) : null + ) + if (normalizedExisting.includes(absPath)) { + return + } + const next = [...existing.filter((e) => typeof e === 'string'), absPath] + config.trustedFolders = next + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } + writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) +} + +function canonicalize(p: string): string { + // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but + // both Cursor and Copilot's trust comparators run realpath() before the + // string compare. Mirror that so a worktree under a symlinked parent + // (orca caches realpath()'d worktree paths) matches the agent's lookup. + try { + if (existsSync(p)) { + return realpathSync(p) + } + } catch { + // Fall through to the raw input. + } + return p +} + +function cursorWorkspaceSlug(absPath: string): string { + const stripped = absPath.replace(/^[\\/]+/, '') + const slug = stripped.replace(/[\\/]+/g, '-') + return slug +} diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index a10dfc4a340..72bbc3b4294 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -1,7 +1,8 @@ import { screen, webContents } from 'electron' import { normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl + normalizeExternalBrowserUrl, + redactKagiSessionToken } from '../../shared/browser-url' import { isWindowShortcutModifierChord, @@ -31,7 +32,10 @@ export function setupGuestContextMenu(args: { if (!renderer) { return } - const pageUrl = guest.getURL() + // Why: redact Kagi session tokens before the URL leaves main; the renderer + // pipes pageUrl into clipboard writes and shell.openExternal, both of which + // would otherwise expose the bearer token outside Orca. + const pageUrl = redactKagiSessionToken(guest.getURL()) // Why: params.linkURL is empty when the user right-clicks non-link // content. Normalizing an empty string through normalizeBrowserNavigationUrl // produces the blank-page constant (a truthy string), which would trick the @@ -238,6 +242,22 @@ export function setupGuestShortcutForwarding(args: { return } + // Why: Cmd/Ctrl+Alt+[ / ] cycles across every tab type. Handled before + // the generic modifier-chord gate below because that gate rejects Alt. + // Mirrors the Alt-exempt branch pattern used for worktreeHistoryNavigate. + const isPrimaryMod = + process.platform === 'darwin' ? input.meta && !input.control : input.control && !input.meta + if ( + isPrimaryMod && + input.alt && + (input.code === 'BracketRight' || input.code === 'BracketLeft') + ) { + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:switchTabAcrossAllTypes', input.code === 'BracketRight' ? 1 : -1) + return + } + // Why: terminal-only tab switching is intentionally Ctrl+PageUp/PageDown on // every platform. Handle it before the primary-modifier gate so macOS Ctrl // (non-primary there) still forwards out of focused browser guests. @@ -264,12 +284,10 @@ export function setupGuestShortcutForwarding(args: { if (input.code === 'KeyB' && input.shift) { renderer.send('ui:newBrowserTab') } else if (input.code === 'KeyT' && !input.shift) { - // Why: once focus is inside a browser guest, Cmd/Ctrl+T should extend - // the current browser workspace with another internal page instead of - // creating a sibling Orca terminal tab. The renderer still decides - // whether that means "new page in this workspace" or "new workspace" - // based on the current active surface. - renderer.send('ui:newBrowserTab') + // Why: Cmd/Ctrl+T always opens a new terminal in the central pane, + // even when focus is inside a browser guest. Cmd/Ctrl+Shift+B is the + // dedicated shortcut for new browser tabs. + renderer.send('ui:newTerminalTab') } else if (input.code === 'KeyL' && !input.shift) { // Why: the address bar lives in the renderer chrome, not the guest // page. Forward Cmd/Ctrl+L out of the guest so the active BrowserPane @@ -302,7 +320,7 @@ export function setupGuestShortcutForwarding(args: { } else if (action?.type === 'openQuickOpen') { renderer.send('ui:openQuickOpen') } else if (action?.type === 'openNewWorkspace') { - renderer.send('ui:openNewWorkspace', action.tab) + renderer.send('ui:openNewWorkspace') } else if (action?.type === 'jumpToWorktreeIndex') { renderer.send('ui:jumpToWorktreeIndex', action.index) } else { diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 308a48347a0..b707c740d66 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -131,6 +131,32 @@ describe('browserManager', () => { }) }) + it('remembers the registered session profile for a browser page', () => { + const guest = { + id: 104, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: guest.id, + rendererWebContentsId, + sessionProfileId: 'work' + }) + + expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') + }) + it('falls back to opening popup URLs externally before a guest is registered', () => { const guest = { id: 105, @@ -1067,7 +1093,7 @@ describe('browserManager', () => { } expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:newBrowserTab') - expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:newBrowserTab') + expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:newTerminalTab') expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:closeActiveTab') expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:switchTab', 1) expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:switchTerminalTab', 1) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 1a5996d1e13..25efea8d022 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto' import { shell, webContents } from 'electron' import { normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl + normalizeExternalBrowserUrl, + redactKagiSessionToken } from '../../shared/browser-url' import type { BrowserDownloadFinishedEvent, @@ -55,6 +56,7 @@ export type BrowserGuestRegistration = { browserTabId?: string workspaceId?: string worktreeId?: string + sessionProfileId?: string | null webContentsId: number rendererWebContentsId: number } @@ -97,6 +99,7 @@ export class BrowserManager { // visibility/focus state is keyed by browser workspace id. Screenshot prep // has to bridge that mismatch to activate the right tab before capture. private readonly workspaceIdByPageId = new Map() + private readonly sessionProfileIdByPageId = new Map() private readonly rendererWebContentsIdByTabId = new Map() // Why: chain setViewportOverride calls per tab so rapid toggles don't // interleave CDP commands. Without serialization, two concurrent calls can @@ -443,7 +446,10 @@ export class BrowserManager { action: 'opened-in-orca' }) } else if (externalUrl) { - void shell.openExternal(externalUrl) + // Why: a target=_blank click on a Kagi search result page produces a + // popup URL that still contains the bearer token; redact before + // handing the URL to the OS default browser. + void shell.openExternal(redactKagiSessionToken(externalUrl)) this.forwardOrQueuePopupEvent(guest.id, { origin: safeOrigin(externalUrl), action: 'opened-external' @@ -543,6 +549,7 @@ export class BrowserManager { browserTabId: legacyBrowserTabId, workspaceId, worktreeId, + sessionProfileId, webContentsId, rendererWebContentsId }: BrowserGuestRegistration): void { @@ -592,6 +599,7 @@ export class BrowserManager { if (workspaceId) { this.workspaceIdByPageId.set(browserTabId, workspaceId) } + this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) if (worktreeId) { this.worktreeIdByTabId.set(browserTabId, worktreeId) @@ -655,6 +663,7 @@ export class BrowserManager { this.webContentsIdByTabId.delete(browserTabId) this.rendererWebContentsIdByTabId.delete(browserTabId) this.workspaceIdByPageId.delete(browserTabId) + this.sessionProfileIdByPageId.delete(browserTabId) this.worktreeIdByTabId.delete(browserTabId) // Why: drop any pending viewport-op chain for this tab so the Map doesn't // retain a resolved promise keyed to a destroyed guest. @@ -681,6 +690,7 @@ export class BrowserManager { this.policyCleanupByGuestId.clear() this.tabIdByWebContentsId.clear() this.worktreeIdByTabId.clear() + this.sessionProfileIdByPageId.clear() this.pendingLoadFailuresByGuestId.clear() this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() @@ -699,6 +709,10 @@ export class BrowserManager { return this.worktreeIdByTabId.get(browserTabId) } + getSessionProfileIdForTab(browserTabId: string): string | null { + return this.sessionProfileIdByPageId.get(browserTabId) ?? null + } + notifyPermissionDenied(args: { guestWebContentsId: number permission: string @@ -1415,9 +1429,15 @@ export class BrowserManager { return } + // Why: redact Kagi session tokens before the validated URL is persisted + // by the renderer into BrowserPage.loadError (saved to disk via the + // workspace session writer). renderer.send('browser:guest-load-failed', { browserPageId: browserTabId, - loadError + loadError: { + ...loadError, + validatedUrl: redactKagiSessionToken(loadError.validatedUrl) + } }) } diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index 37a89caea3d..8475a0cbca8 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -273,7 +273,7 @@ describe('Browser automation pipeline (integration)', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath)! - endpoint = metadata.transport!.endpoint + endpoint = metadata.transports[0]!.endpoint authToken = metadata.authToken! }) @@ -521,7 +521,7 @@ describe('Browser automation pipeline (integration)', () => { await server2.start() const metadata2 = readRuntimeMetadata(userDataPath2)! - const res = await sendRequest(metadata2.transport!.endpoint, { + const res = await sendRequest(metadata2.transports[0]!.endpoint, { id: 'req_no_tab', authToken: metadata2.authToken, method: 'browser.snapshot' diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts new file mode 100644 index 00000000000..6957a58131c --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -0,0 +1,1047 @@ +/* eslint-disable max-lines -- test suite covers Claude runtime auth refresh, identity guards, and snapshot restore cases */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultSettings } from '../../shared/constants' +import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types' + +const testState = { + userDataDir: '', + fakeHomeDir: '', + activeKeychainCredentials: null as string | null, + managedKeychainCredentials: new Map() +} + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => ({ + readActiveClaudeKeychainCredentials: vi.fn(async () => testState.activeKeychainCredentials), + writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string) => { + testState.activeKeychainCredentials = contents + }), + deleteActiveClaudeKeychainCredentials: vi.fn(async () => { + testState.activeKeychainCredentials = null + }), + readManagedClaudeKeychainCredentials: vi.fn( + async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null + ), + writeManagedClaudeKeychainCredentials: vi.fn(async (accountId: string, contents: string) => { + testState.managedKeychainCredentials.set(accountId, contents) + }) +})) + +function createSettings(overrides: Partial = {}): GlobalSettings { + return { + ...getDefaultSettings(testState.fakeHomeDir), + ...overrides + } +} + +function createStore(settings: GlobalSettings) { + return { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { + ...settings, + ...updates, + notifications: { + ...settings.notifications, + ...updates.notifications + } + } + return settings + }) + } +} + +function createManagedClaudeAuth( + rootDir: string, + accountId: string, + credentialsJson: string, + oauthAccountJson = `{"accountUuid":"${accountId}"}\n` +): string { + const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8') + testState.managedKeychainCredentials.set(accountId, credentialsJson) + return managedAuthPath +} + +function createClaudeAccount( + id: string, + managedAuthPath: string, + overrides: Partial = {} +): ClaudeManagedAccount { + return { + id, + email: 'user@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...overrides + } +} + +function createClaudeCredentialsJson( + email: string, + accessToken: string, + organizationUuid: string | null = null, + expiresAt = Date.now() + 60_000 +): string { + return `${JSON.stringify({ + claudeAiOauth: { + email, + ...(organizationUuid ? { organizationUuid } : {}), + accessToken, + refreshToken: `${accessToken}-refresh`, + expiresAt + } + })}\n` +} + +function createClaudeCredentialsWithoutEmail( + accessToken: string, + organizationUuid: string | null = null +): string { + return `${JSON.stringify({ + claudeAiOauth: { + ...(organizationUuid ? { organizationUuid } : {}), + accessToken, + refreshToken: `${accessToken}-refresh`, + expiresAt: Date.now() + 60_000 + } + })}\n` +} + +function readManagedCredentialsForTest(accountId: string, managedAuthPath: string): string | null { + if (process.platform === 'darwin') { + return testState.managedKeychainCredentials.get(accountId) ?? null + } + return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8') +} + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + testState.activeKeychainCredentials = null + testState.managedKeychainCredentials.clear() + testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-')) + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-home-')) + mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true }) + }) + + afterEach(() => { + rmSync(testState.userDataDir, { recursive: true, force: true }) + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + }) + + it('rematerializes unchanged managed credentials when the runtime file is missing', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + '{"token":"managed"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n') + + rmSync(runtimeCredentialsPath, { force: true }) + await service.prepareForClaudeLaunch() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n') + }) + + it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + writeFileSync(runtimeCredentialsPath, '{"token":"managed"}\n', 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + '{"token":"managed"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(runtimeCredentialsPath)).toBe(false) + if (process.platform === 'darwin') { + expect(testState.activeKeychainCredentials).toBeNull() + } + }) + + it('falls back to atomic write when the unchanged check cannot read the target', async () => { + if (process.platform === 'win32') { + return + } + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + '{"token":"managed"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + testState.managedKeychainCredentials.set('account-1', '{"token":"rotated"}\n') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"token":"rotated"}\n', 'utf-8') + chmodSync(runtimeCredentialsPath, 0o000) + try { + await service.syncForCurrentSelection() + } finally { + if (existsSync(runtimeCredentialsPath)) { + chmodSync(runtimeCredentialsPath, 0o600) + } + warn.mockRestore() + } + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"rotated"}\n') + }) + + it('tightens credential file permissions when unchanged content is already present', async () => { + if (process.platform === 'win32') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + '{"token":"managed"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + chmodSync(runtimeCredentialsPath, 0o644) + await service.syncForCurrentSelection() + + expect(statSync(runtimeCredentialsPath).mode & 0o777).toBe(0o600) + }) + + it('reads back refreshed credentials when the Claude identity still matches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('reads back verified same-account credentials on first sync after restart', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'original', + null, + 1_000 + ) + const refreshedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'refreshed', + null, + 2_000 + ) + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('rejects older same-account Claude credentials on first sync after restart', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const staleRuntimeCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'stale', + null, + 1_000 + ) + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed-newer', + null, + 2_000 + ) + writeFileSync(runtimeCredentialsPath, staleRuntimeCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + }) + + it('rejects runtime read-back from a different Claude identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('other@example.com', 'stale') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects runtime read-back from the same Claude email in a different organization', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects same-email Claude read-back using stored managed organization identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects same-email Claude read-back using stored oauth-account organization identity', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials, + '{"organizationUuid":"org-b"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects no-email Claude read-back when organization identity conflicts', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b') + const staleCredentials = createClaudeCredentialsWithoutEmail('stale', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) + }) + + it('rejects unverifiable refreshed runtime credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsWithoutEmail('original') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) + }) + + it('restores the system default after rejecting unverifiable managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const originalCredentials = createClaudeCredentialsWithoutEmail('original') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('restores system default after same-identity managed Claude refresh on deselect', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed', 'org-1') + const externalCredentials = createClaudeCredentialsJson('user@example.com', 'external', 'org-1') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { + organizationUuid: 'org-1' + }) + ] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(externalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('restores system default when stale Claude credentials are rejected on deselect', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected') + const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + selectedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'selected@example.com' }) + ] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('does not persist unverifiable stale Claude credentials into another active account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const staleUnverifiableCredentials = createClaudeCredentialsWithoutEmail('stale') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + writeFileSync(runtimeCredentialsPath, staleUnverifiableCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + }) + + it('does not carry the reauth read-back skip across Claude account switches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const account2RefreshedCredentials = createClaudeCredentialsJson( + 'two@example.com', + 'two-refreshed' + ) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + service.clearLastWrittenCredentialsJson() + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account2RefreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe( + account2RefreshedCredentials + ) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2RefreshedCredentials) + }) + + it('does not apply inactive-account Claude reauth skip to the active account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account1RefreshedCredentials = createClaudeCredentialsJson( + 'one@example.com', + 'one-refreshed' + ) + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') + service.clearLastWrittenCredentialsJson('account-2') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( + account1RefreshedCredentials + ) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) + }) + + it('keeps external Claude logout when deselecting managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + rmSync(runtimeCredentialsPath, { force: true }) + testState.activeKeychainCredentials = null + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(runtimeCredentialsPath)).toBe(false) + }) + + it('reads back refreshed active keychain credentials on macOS', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.activeKeychainCredentials = refreshedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('restores system default when mismatched Claude keychain auth appears before deselect', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalKeychainCredentials = createClaudeCredentialsJson( + 'external@example.com', + 'external' + ) + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.activeKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + testState.activeKeychainCredentials = externalKeychainCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.activeKeychainCredentials).toBe(systemCredentials) + }) + + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') + const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + writeFileSync(runtimeCredentialsPath, systemCredentials2, 'utf-8') + + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2) + }) + + it('reads back refreshed credentials for the outgoing Claude account before switching', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') + const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Original + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + }) + + it('routes refreshed Claude credentials to the matching managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') + const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Original + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-2' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + // A stale account-1 Claude process refreshed the shared runtime file after + // Orca selected account-2. Persist that refresh to account-1, then restore + // the selected account in the shared Claude runtime credentials. + writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') + testState.activeKeychainCredentials = account1Refreshed + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + if (process.platform === 'darwin') { + expect(testState.activeKeychainCredentials).toBe(account2Credentials) + } + }) + + it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original') + const refreshedCredentials = createClaudeCredentialsJson('same@example.com', 'same-refreshed') + const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + originalCredentials + ) + const managedAuthPath3 = createManagedClaudeAuth( + testState.userDataDir, + 'account-3', + activeCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'same@example.com' }), + createClaudeAccount('account-3', managedAuthPath3, { email: 'active@example.com' }) + ], + activeClaudeManagedAccountId: 'account-3' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + testState.activeKeychainCredentials = refreshedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials) + if (process.platform === 'darwin') { + expect(testState.activeKeychainCredentials).toBe(activeCredentials) + } + }) + + it('clears an invalid active Claude account before launch preparation', async () => { + const settings = createSettings({ + activeClaudeManagedAccountId: 'missing-account' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(store.updateSettings).toHaveBeenCalledWith({ activeClaudeManagedAccountId: null }) + expect(preparation.stripAuthEnv).toBe(false) + expect(preparation.provenance).toBe('system') + }) + + 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') + const reauthedCredentials = createClaudeCredentialsJson('user@example.com', 'reauthed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.managedKeychainCredentials.set('account-1', reauthedCredentials) + writeFileSync(join(managedAuthPath, '.credentials.json'), reauthedCredentials, 'utf-8') + service.clearLastWrittenCredentialsJson() + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 3c9f32c3648..31c7e3104ce 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,7 +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 { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { app } from 'electron' import type { ClaudeManagedAccount } from '../../shared/types' @@ -30,6 +30,16 @@ type ClaudeSystemDefaultSnapshot = { capturedAt: number } +type ClaudeAuthIdentity = { + email: string | null + organizationUuid: string | null +} + +type ClaudeReadBackResult = { status: 'unchanged' | 'persisted' | 'rejected' } +type ClaudeReadBackMatch = + | { kind: 'matched'; account: ClaudeManagedAccount } + | { kind: 'none' | 'ambiguous' } + export class ClaudeRuntimeAuthService { private readonly pathResolver = new ClaudeRuntimePathResolver() private mutationQueue: Promise = Promise.resolve() @@ -39,6 +49,7 @@ export class ClaudeRuntimeAuthService { // an external login (e.g. `claude auth login`) overwrote it — so Orca adopts // the file as the new system default instead of restoring a stale snapshot. private lastWrittenCredentialsJson: string | null = null + private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { this.initializeLastSyncedState() @@ -63,7 +74,7 @@ export class ClaudeRuntimeAuthService { await this.serializeMutation(async () => { const settings = this.store.getSettings() if (!settings.activeClaudeManagedAccountId) { - await this.restoreSystemDefaultSnapshot() + await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null return } @@ -100,37 +111,66 @@ export class ClaudeRuntimeAuthService { settings.claudeManagedAccounts, settings.activeClaudeManagedAccountId ) + const previousAccount = this.getActiveAccount( + settings.claudeManagedAccounts, + this.lastSyncedAccountId + ) + let outgoingReadBackResult: ClaudeReadBackResult = { status: 'unchanged' } + if (previousAccount && previousAccount.id !== activeAccount?.id) { + outgoingReadBackResult = await this.readBackRefreshedTokensForAccount(previousAccount, { + updateLastWrittenCredentialsJson: true + }) + } if (!activeAccount) { + if (settings.activeClaudeManagedAccountId) { + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + } if (this.lastSyncedAccountId !== null) { - await this.restoreSystemDefaultSnapshot() + await this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult.status !== 'rejected' + }) this.lastSyncedAccountId = null } return } - await this.captureSystemDefaultSnapshotIfNeeded() - - const credentialsJson = await this.readManagedCredentials(activeAccount) + let credentialsJson = await this.readManagedCredentials(activeAccount) if (!credentialsJson) { console.warn( '[claude-runtime-auth] Active managed account is missing credentials, restoring system default' ) this.store.updateSettings({ activeClaudeManagedAccountId: null }) if (this.lastSyncedAccountId !== null) { - await this.restoreSystemDefaultSnapshot() + await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null } return } + if (this.lastSyncedAccountId === null) { + await this.captureSystemDefaultSnapshot({ force: true }) + } + // Why: Claude CLI refreshes expired OAuth tokens and writes them back to // .credentials.json. If we detect the runtime file differs from what Orca // last wrote, the CLI must have refreshed — so we preserve those tokens // back to managed storage before overwriting runtime with managed state. if (this.lastSyncedAccountId === activeAccount.id) { - await this.readBackRefreshedTokens(activeAccount) + if (this.skipNextReadBackForAccountId === activeAccount.id) { + this.skipNextReadBackForAccountId = null + } else { + const readBackResult = await this.readBackRefreshedTokens(credentialsJson, { + updateLastWrittenCredentialsJson: true + }) + if (readBackResult.status === 'persisted') { + credentialsJson = (await this.readManagedCredentials(activeAccount)) ?? credentialsJson + } + } } + if (this.lastSyncedAccountId !== activeAccount.id) { + this.skipNextReadBackForAccountId = null + } this.writeRuntimeCredentials(credentialsJson) if (process.platform === 'darwin') { await writeActiveClaudeKeychainCredentials(credentialsJson) @@ -143,40 +183,105 @@ export class ClaudeRuntimeAuthService { // 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. - clearLastWrittenCredentialsJson(): void { - this.lastWrittenCredentialsJson = null + clearLastWrittenCredentialsJson( + accountId = this.store.getSettings().activeClaudeManagedAccountId + ): void { + if (accountId === this.store.getSettings().activeClaudeManagedAccountId) { + this.lastWrittenCredentialsJson = null + } + this.skipNextReadBackForAccountId = accountId } - private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise { + private async readBackRefreshedTokens( + baselineCredentialsJson: string, + options: { updateLastWrittenCredentialsJson: boolean } + ): Promise { try { - if (this.lastWrittenCredentialsJson === null) { - return + const runtimeContents = await this.readRuntimeCredentialsForReadBack(baselineCredentialsJson) + if (!runtimeContents) { + return { status: 'unchanged' } + } + if ( + this.lastWrittenCredentialsJson !== null && + runtimeContents === this.lastWrittenCredentialsJson + ) { + return { status: 'unchanged' } } - const paths = this.pathResolver.getRuntimePaths() - if (!existsSync(paths.credentialsPath)) { - return + const match = await this.findManagedAccountForRuntimeCredentials(runtimeContents) + if (match.kind !== 'matched') { + if (match.kind === 'ambiguous') { + console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back') + } + return { status: 'rejected' } + } + // Why: on cold app start we cannot tell whether matching runtime + // credentials are a fresh CLI refresh or stale state unless token + // metadata proves runtime is newer than managed storage. + if ( + this.lastWrittenCredentialsJson === null && + !this.runtimeCredentialsAreFresher(runtimeContents, baselineCredentialsJson) + ) { + return { status: 'rejected' } } - const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8') - if (runtimeContents === this.lastWrittenCredentialsJson) { - return - } - - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(account.id, runtimeContents) - } else { - const credentialsPath = join(account.managedAuthPath, '.credentials.json') - writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 }) + await this.writeManagedCredentials(match.account, runtimeContents) + if (options.updateLastWrittenCredentialsJson) { + this.writeRuntimeCredentials(runtimeContents) + this.lastWrittenCredentialsJson = runtimeContents + if (process.platform === 'darwin') { + await writeActiveClaudeKeychainCredentials(runtimeContents) + } } + return { status: 'persisted' } } catch (error) { // Why: read-back is best-effort. A transient fs error must not block the // forward sync path — the worst case is one more stale-token cycle, which // is strictly better than failing the entire sync. console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error) + return { status: 'rejected' } } } + private async readRuntimeCredentialsForReadBack( + baselineCredentialsJson: string + ): Promise { + const paths = this.pathResolver.getRuntimePaths() + const fileCredentials = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + if (process.platform === 'darwin') { + const keychainCredentials = await readActiveClaudeKeychainCredentials() + if (this.lastWrittenCredentialsJson === null) { + if (keychainCredentials && keychainCredentials !== baselineCredentialsJson) { + return keychainCredentials + } + if (fileCredentials && fileCredentials !== baselineCredentialsJson) { + return fileCredentials + } + return keychainCredentials ?? fileCredentials + } + if (keychainCredentials && keychainCredentials !== this.lastWrittenCredentialsJson) { + return keychainCredentials + } + } + if (!fileCredentials) { + return null + } + return fileCredentials + } + + private async readBackRefreshedTokensForAccount( + account: ClaudeManagedAccount, + options: { updateLastWrittenCredentialsJson: boolean } + ): Promise { + const managedCredentialsJson = await this.readManagedCredentials(account) + if (!managedCredentialsJson) { + return { status: 'unchanged' } + } + return this.readBackRefreshedTokens(managedCredentialsJson, options) + } + private getPreparation(): ClaudeRuntimeAuthPreparation { const settings = this.store.getSettings() const paths = this.pathResolver.getRuntimePaths() @@ -198,6 +303,161 @@ export class ClaudeRuntimeAuthService { return accounts.find((account) => account.id === activeAccountId) ?? null } + private async findManagedAccountForRuntimeCredentials( + runtimeCredentialsJson: string + ): Promise { + const matches: ClaudeManagedAccount[] = [] + for (const account of this.store.getSettings().claudeManagedAccounts) { + const managedCredentialsJson = await this.readManagedCredentials(account) + if (!managedCredentialsJson) { + continue + } + if ( + this.runtimeCredentialsMatchAccount( + runtimeCredentialsJson, + account, + managedCredentialsJson, + this.readManagedOauthAccount(account) + ) === 'match' + ) { + matches.push(account) + } + } + + if (matches.length === 1) { + return { kind: 'matched', account: matches[0] } + } + return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + } + + private runtimeCredentialsMatchAccount( + runtimeCredentialsJson: string, + account: ClaudeManagedAccount, + managedCredentialsJson: string, + managedOauthAccount: unknown + ): 'match' | 'mismatch' | 'unverifiable' { + const identity = this.readIdentityFromCredentials(runtimeCredentialsJson) + if (!identity) { + return 'mismatch' + } + const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson) + const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) + + // Why: this mirrors the Codex runtime-home guard. If another Claude login + // or missed live process rewrites shared runtime credentials, do not + // persist those credentials into the selected managed account. + const selectedOrganizationUuid = this.normalizeField( + account.organizationUuid ?? + managedIdentity?.organizationUuid ?? + managedOauthIdentity.organizationUuid + ) + if (selectedOrganizationUuid && !identity.organizationUuid) { + return 'unverifiable' + } + if ( + selectedOrganizationUuid && + identity.organizationUuid && + selectedOrganizationUuid !== identity.organizationUuid + ) { + return 'mismatch' + } + if (!identity.email) { + return 'unverifiable' + } + if (account.email && identity.email && this.normalizeField(account.email) !== identity.email) { + return 'mismatch' + } + + return 'match' + } + + private readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null { + let parsed: Record + try { + parsed = JSON.parse(credentialsJson) as Record + } catch { + return null + } + const oauth = this.asRecord(parsed.claudeAiOauth) + return { + email: this.normalizeField(this.readString(oauth, 'email')), + organizationUuid: this.normalizeField( + this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') + ) + } + } + + private runtimeCredentialsAreFresher( + runtimeCredentialsJson: string, + managedCredentialsJson: string + ): boolean { + const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) + const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) + return ( + runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness + ) + } + + private readFreshnessFromCredentials(credentialsJson: string): number | null { + let parsed: Record + try { + parsed = JSON.parse(credentialsJson) as Record + } catch { + return null + } + const oauth = this.asRecord(parsed.claudeAiOauth) + return ( + this.readNumber(oauth, 'expiresAt') ?? + this.readNumber(oauth, 'expires_at') ?? + this.readNumber(oauth, 'expiry') ?? + this.readNumber(oauth, 'expires') + ) + } + + private readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity { + const oauth = this.asRecord(oauthAccount) + return { + email: this.normalizeField( + this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email') + ), + organizationUuid: this.normalizeField( + this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId') + ) + } + } + + private asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + return value as Record + } + + private readString(value: Record | null, key: string): string | null { + const candidate = value?.[key] + return typeof candidate === 'string' ? candidate : null + } + + private readNumber(value: Record | null, key: string): number | null { + const candidate = value?.[key] + if (typeof candidate === 'number' && Number.isFinite(candidate)) { + return candidate + } + if (typeof candidate === 'string') { + const parsed = Number(candidate) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + + private normalizeField(value: string | null | undefined): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed === '' ? null : trimmed + } + private async readManagedCredentials(account: ClaudeManagedAccount): Promise { if (process.platform === 'darwin') { return readManagedClaudeKeychainCredentials(account.id) @@ -209,6 +469,18 @@ export class ClaudeRuntimeAuthService { return readFileSync(credentialsPath, 'utf-8') } + private async writeManagedCredentials( + account: ClaudeManagedAccount, + credentialsJson: string + ): Promise { + if (process.platform === 'darwin') { + await writeManagedClaudeKeychainCredentials(account.id, credentialsJson) + return + } + const credentialsPath = join(account.managedAuthPath, '.credentials.json') + writeFileAtomically(credentialsPath, credentialsJson, { mode: 0o600 }) + } + private readManagedOauthAccount(account: ClaudeManagedAccount): unknown { const oauthPath = join(account.managedAuthPath, 'oauth-account.json') if (!existsSync(oauthPath)) { @@ -221,9 +493,9 @@ export class ClaudeRuntimeAuthService { } } - private async captureSystemDefaultSnapshotIfNeeded(): Promise { + private async captureSystemDefaultSnapshot(options: { force: boolean }): Promise { const snapshotPath = this.getSystemDefaultSnapshotPath() - if (existsSync(snapshotPath)) { + if (!options.force && existsSync(snapshotPath)) { return } @@ -241,13 +513,23 @@ export class ClaudeRuntimeAuthService { this.writeJson(snapshotPath, snapshot) } - private async restoreSystemDefaultSnapshot(): Promise { - if (this.detectExternalLoginAndUpdateSnapshot()) { + private async restoreSystemDefaultSnapshot(options: { + detectExternalLogin: boolean + }): Promise { + const externalState = options.detectExternalLogin + ? await this.detectExternalLoginAndUpdateSnapshot() + : 'none' + if (externalState === 'file-logout') { return } const snapshotPath = this.getSystemDefaultSnapshotPath() if (!existsSync(snapshotPath)) { + rmSync(this.pathResolver.getRuntimePaths().credentialsPath, { force: true }) + if (process.platform === 'darwin') { + await deleteActiveClaudeKeychainCredentials() + } + this.lastWrittenCredentialsJson = null return } const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as ClaudeSystemDefaultSnapshot @@ -258,9 +540,11 @@ export class ClaudeRuntimeAuthService { } this.writeRuntimeOauthAccount(snapshot.configOauthAccount) if (process.platform === 'darwin') { - await (snapshot.keychainCredentialsJson !== null - ? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson) - : deleteActiveClaudeKeychainCredentials()) + if (externalState !== 'keychain-change') { + await (snapshot.keychainCredentialsJson !== null + ? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson) + : deleteActiveClaudeKeychainCredentials()) + } } } @@ -268,23 +552,43 @@ export class ClaudeRuntimeAuthService { // the credentials file while a managed account was active. If the file // differs from what Orca last wrote, that external login becomes the new // system default — no manual "refresh" button needed. - private detectExternalLoginAndUpdateSnapshot(): boolean { + private async detectExternalLoginAndUpdateSnapshot(): Promise< + 'none' | 'file-logout' | 'keychain-change' + > { if (this.lastWrittenCredentialsJson === null) { - return false + return 'none' } const paths = this.pathResolver.getRuntimePaths() if (!existsSync(paths.credentialsPath)) { - return false + const currentKeychainCredentials = + process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null + if ( + process.platform === 'darwin' && + currentKeychainCredentials === this.lastWrittenCredentialsJson + ) { + return 'none' + } + const snapshotPath = this.getSystemDefaultSnapshotPath() + rmSync(snapshotPath, { force: true }) + this.lastWrittenCredentialsJson = null + return 'file-logout' } const currentCredentials = readFileSync(paths.credentialsPath, 'utf-8') + const currentKeychainCredentials = + process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null if (currentCredentials === this.lastWrittenCredentialsJson) { - return false + if ( + process.platform === 'darwin' && + currentKeychainCredentials !== this.lastWrittenCredentialsJson + ) { + return 'keychain-change' + } + return 'none' } - // External login detected — adopt current state as the new system default const snapshotPath = this.getSystemDefaultSnapshotPath() rmSync(snapshotPath, { force: true }) this.lastWrittenCredentialsJson = null - return true + return 'file-logout' } private readRuntimeOauthAccount(): unknown { @@ -314,13 +618,53 @@ export class ClaudeRuntimeAuthService { private writeRuntimeCredentials(contents: string): void { const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath mkdirSync(dirname(credentialsPath), { recursive: true }) + // Why: repeated Claude spawns sync auth, but credentials rarely change. + // Skipping unchanged rewrites avoids Windows EPERM contention in #1507. + // Still verify the file because another Claude process may have rewritten + // runtime credentials since Orca last materialized them. + if ( + this.lastWrittenCredentialsJson === contents && + this.fileContentsEqual(credentialsPath, contents) + ) { + this.ensureOwnerOnlyMode(credentialsPath) + return + } + if (this.fileContentsEqual(credentialsPath, contents)) { + this.ensureOwnerOnlyMode(credentialsPath) + this.lastWrittenCredentialsJson = contents + return + } writeFileAtomically(credentialsPath, contents, { mode: 0o600 }) this.lastWrittenCredentialsJson = contents } private writeJson(targetPath: string, value: unknown): void { + const serialized = `${JSON.stringify(value, null, 2)}\n` mkdirSync(dirname(targetPath), { recursive: true }) - writeFileAtomically(targetPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }) + // Why: same Windows contention reason as writeRuntimeCredentials. + if (this.fileContentsEqual(targetPath, serialized)) { + return + } + writeFileAtomically(targetPath, serialized, { mode: 0o600 }) + } + + private fileContentsEqual(targetPath: string, contents: string): boolean { + try { + return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents + } catch { + return false + } + } + + private ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } } private readJsonObject(targetPath: string): Record { diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 78324c2ca4a..b2a8dd90130 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -115,7 +115,7 @@ export class ClaudeAccountService { claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], activeClaudeManagedAccountId: account.id }) - this.runtimeAuth.clearLastWrittenCredentialsJson() + this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) await this.syncRuntimeAuthWithLivePtyGate() await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) return this.getSnapshot() @@ -154,7 +154,7 @@ export class ClaudeAccountService { ) }) try { - this.runtimeAuth.clearLastWrittenCredentialsJson() + this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) await this.syncRuntimeAuthWithLivePtyGate() await this.rateLimits.refreshForClaudeAccountChange() return this.getSnapshot() diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index fd1d74b5d76..e3e1ff416a2 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -177,4 +177,58 @@ describe('ClaudeUsageStore', () => { expect(summary.turns).toBe(5) expect(summary.zeroCacheReadTurns).toBe(2) }) + + it('prices Claude Opus 4.7 with current Anthropic rates', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'claude-opus-4-7-20260416', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000 + } + ] + }) + + const summary = await store.getSummary('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(summary.estimatedCostUsd).toBeCloseTo(36.75) + expect( + breakdown.find((row) => row.key === 'claude-opus-4-7-20260416')?.estimatedCostUsd + ).toBeCloseTo(36.75) + }) + + it('does not collapse older Opus 4.1 usage into current Opus 4.7 pricing', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'claude-opus-4-1-20250805', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000 + } + ] + }) + + const summary = await store.getSummary('orca', '30d') + + expect(summary.estimatedCostUsd).toBeCloseTo(110.25) + }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index da7dfdd2b66..476782abf44 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -2,7 +2,6 @@ import { app } from 'electron' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' import { dirname, join } from 'path' -import type { Repo } from '../../shared/types' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, @@ -13,16 +12,10 @@ import type { ClaudeUsageSessionRow, ClaudeUsageSummary } from '../../shared/claude-usage-types' -import { listRepoWorktrees } from '../repo-worktrees' import type { Store } from '../persistence' -import { mergeWorktree } from '../ipc/worktree-logic' +import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' -import { - createWorktreeRefs, - getDefaultWorktreeLabel, - getSessionProjectLabel, - scanClaudeUsageFiles -} from './scanner' +import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' const SCHEMA_VERSION = 1 const STALE_MS = 5 * 60_000 @@ -35,11 +28,18 @@ const MODEL_PRICING: Record< string, { input: number; output: number; cacheRead: number; cacheWrite: number } > = { - 'claude-opus-4-6': { input: 6.15, output: 30.75, cacheRead: 0.61, cacheWrite: 7.69 }, - 'claude-opus-4-5': { input: 6.15, output: 30.75, cacheRead: 0.61, cacheWrite: 7.69 }, - 'claude-sonnet-4-6': { input: 3.69, output: 18.45, cacheRead: 0.37, cacheWrite: 4.61 }, - 'claude-sonnet-4-5': { input: 3.69, output: 18.45, cacheRead: 0.37, cacheWrite: 4.61 }, - 'claude-haiku-4-5': { input: 1.23, output: 6.15, cacheRead: 0.12, cacheWrite: 1.54 } + 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-sonnet-4-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-sonnet-4': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }, + 'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 } } function getDefaultState(): ClaudeUsagePersistedState { @@ -73,15 +73,51 @@ function normalizeModelForPricing(model: string | null): string | null { return null } const lower = model.toLowerCase() - if (lower.includes('opus')) { + if (lower.includes('opus-4-7')) { + return 'claude-opus-4-7' + } + if (lower.includes('opus-4-6')) { return 'claude-opus-4-6' } - if (lower.includes('sonnet')) { + if (lower.includes('opus-4-5')) { + return 'claude-opus-4-5' + } + if (lower.includes('opus-4-1')) { + return 'claude-opus-4-1' + } + if (lower.includes('opus-4')) { + return 'claude-opus-4' + } + if (lower.includes('sonnet-4-6')) { return 'claude-sonnet-4-6' } - if (lower.includes('haiku')) { + if (lower.includes('sonnet-4-5')) { + return 'claude-sonnet-4-5' + } + if (lower.includes('sonnet-4')) { + return 'claude-sonnet-4' + } + if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) { + return 'claude-sonnet-3-7' + } + // Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still + // present in historical Claude Code/SDK logs read off disk. Match them so + // their cost is not silently dropped from the breakdown. + if (lower.includes('3-5-sonnet') || lower.includes('3.5-sonnet')) { + return 'claude-sonnet-3-7' + } + if (lower.includes('haiku-4-5')) { return 'claude-haiku-4-5' } + if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) { + return 'claude-haiku-3-5' + } + if (lower.includes('3-5-haiku') || lower.includes('3.5-haiku')) { + return 'claude-haiku-3-5' + } + if (lower.includes('haiku-3')) { + return 'claude-haiku-3' + } return null } @@ -131,12 +167,6 @@ function getLocalDay(timestamp: string): string | null { return `${year}-${month}-${day}` } -type ClaudeUsageWorktree = { - worktreeId: string - path: string - displayName: string -} - export class ClaudeUsageStore { private state: ClaudeUsagePersistedState private readonly store: Store @@ -226,7 +256,7 @@ export class ClaudeUsageStore { this.scanPromise = (async () => { try { const repos = this.store.getRepos() - const worktreesByRepo = await this.loadWorktreesByRepo(repos) + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) const result = await scanClaudeUsageFiles(createWorktreeRefs(repos, worktreesByRepo)) this.state.processedFiles = result.processedFiles this.state.sessions = result.sessions @@ -502,25 +532,4 @@ export class ClaudeUsageStore { return true }) } - - private async loadWorktreesByRepo(repos: Repo[]): Promise> { - const worktreesByRepo = new Map() - - for (const repo of repos) { - const gitWorktrees = await listRepoWorktrees(repo) - const mapped = gitWorktrees.map((worktree) => { - const worktreeId = `${repo.id}::${worktree.path}` - const meta = this.store.getWorktreeMeta(worktreeId) - const merged = mergeWorktree(repo.id, worktree, meta, repo.displayName) - return { - worktreeId, - path: worktree.path, - displayName: merged.displayName || getDefaultWorktreeLabel(worktree.path) - } - }) - worktreesByRepo.set(repo.id, mapped) - } - - return worktreesByRepo - } } diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index a78445ec19f..d8b85e3ad1f 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -6,6 +6,7 @@ import { createManagedCommandMatcher, readHooksJson, removeManagedCommands, + wrapPosixHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -48,13 +49,16 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - // Why: on Windows, Claude Code runs hooks through Git Bash (`/usr/bin/bash`). - // A path with single backslashes (e.g. `C:\Users\…\claude-hook.cmd`) is - // interpreted by bash as a string with escape sequences, so `\U`, `\A`, etc. - // collapse and the launcher fails with `command not found`. Emit forward - // slashes — Windows accepts them in path arguments and bash leaves them - // intact, so the same JSON value works through every shell layer. - return process.platform === 'win32' ? scriptPath.replaceAll('\\', '/') : `/bin/sh "${scriptPath}"` + if (process.platform === 'win32') { + // Why: on Windows, Claude Code runs hooks through Git Bash (`/usr/bin/bash`). + // A path with single backslashes (e.g. `C:\Users\…\claude-hook.cmd`) is + // interpreted by bash as a string with escape sequences, so `\U`, `\A`, etc. + // collapse and the launcher fails with `command not found`. Emit forward + // slashes — Windows accepts them in path arguments and bash leaves them + // intact, so the same JSON value works through every shell layer. + return scriptPath.replaceAll('\\', '/') + } + return wrapPosixHookCommand(scriptPath) } function getManagedScript(): string { diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 407176357c4..592256e89a6 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -39,26 +39,31 @@ export function writeFileAtomically( } } -// Why: on Windows, renameSync can fail with EPERM/EACCES if another process -// (antivirus, Codex CLI) holds the target file open. A short retry avoids -// transient failures without masking real permission errors. +// Why: on Windows, renameSync can fail with EPERM/EACCES/EBUSY if another +// process (antivirus, Claude CLI, Codex CLI) holds the target file open. +// A short retry avoids transient failures without masking real permission +// errors. Total backoff (~750ms) covers typical AV scan windows seen in +// issue #1507. function renameWithRetry(source: string, target: string): void { - const maxAttempts = process.platform === 'win32' ? 3 : 1 + const maxAttempts = process.platform === 'win32' ? 6 : 1 for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { renameSync(source, target) return } catch (error) { const code = (error as NodeJS.ErrnoException).code - if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES')) { - const delayMs = attempt * 50 - const until = Date.now() + delayMs - while (Date.now() < until) { - /* busy-wait: setTimeout is async and callers must stay sync */ - } + if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY')) { + sleepSync(attempt * 50) continue } throw error } } } + +// Why: writeFileAtomically is a sync API called from sync paths, so the retry +// backoff must park the thread instead of burning CPU in a Date.now() loop. +const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) +function sleepSync(ms: number): void { + Atomics.wait(sleepBuffer, 0, 0, ms) +} diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index ed16d9ab18d..091d223fa37 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- test suite covers snapshot, migration, auth materialization, and error-resilience scenarios */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + chmodSync, existsSync, mkdtempSync, mkdirSync, @@ -30,6 +31,7 @@ vi.mock('node:os', async () => { }) function createSettings(overrides: Partial = {}): GlobalSettings { + const appFontFamily = overrides.appFontFamily ?? 'Geist' return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -65,14 +67,15 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalScrollbackBytes: 10_000_000, openLinksInApp: false, rightSidebarOpenByDefault: true, - showTitlebarAgentActivity: true, + showTitlebarAppName: true, showTasksButton: true, diffDefaultView: 'inline', notifications: { enabled: true, agentTaskComplete: true, terminalBell: false, - suppressWhenFocused: true + suppressWhenFocused: true, + customSoundPath: null }, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, @@ -93,12 +96,16 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, - experimentalAgentDashboard: false, - experimentalSidekick: false, + experimentalMobile: false, + mobileAutoRestoreFitMs: null, + experimentalPet: false, + experimentalActivity: false, + experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, - ...overrides + ...overrides, + appFontFamily } } @@ -127,6 +134,39 @@ function createManagedAuth(rootDir: string, accountId: string, auth: string): st return managedHomePath } +function encodeJwtPart(value: Record): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +function createCodexAuthJson( + email: string, + accountId: string, + refreshToken: string, + expiresAt?: number +): string { + const idToken = [ + encodeJwtPart({ alg: 'none', typ: 'JWT' }), + encodeJwtPart({ + email, + ...(expiresAt === undefined ? {} : { exp: expiresAt }), + 'https://api.openai.com/auth': { + chatgpt_account_id: accountId, + workspace_account_id: accountId + } + }), + '' + ].join('.') + + return `${JSON.stringify({ + tokens: { + id_token: idToken, + account_id: accountId, + ...(expiresAt === undefined ? {} : { expires_at: expiresAt }), + refresh_token: refreshToken + } + })}\n` +} + describe('CodexRuntimeHomeService', () => { beforeEach(() => { vi.resetModules() @@ -150,11 +190,14 @@ describe('CodexRuntimeHomeService', () => { new CodexRuntimeHomeService(store as never) expect( - readFileSync( - join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'), - 'utf-8' - ) - ).toBe('{"account":"system"}\n') + existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + ).toBe(true) + if (process.platform !== 'win32') { + expect( + statSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + .mode & 0o777 + ).toBe(0o600) + } }) it('materializes the active managed account auth into ~/.codex on startup', async () => { @@ -189,11 +232,8 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') expect( - readFileSync( - join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'), - 'utf-8' - ) - ).toBe('{"account":"system"}\n') + existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + ).toBe(false) }) it('restores the system-default snapshot when no managed account is selected', async () => { @@ -204,6 +244,43 @@ describe('CodexRuntimeHomeService', () => { 'account-1', '{"account":"managed"}\n' ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + writeFileSync(runtimeAuthPath, '{"account":"managed"}\n', 'utf-8') + + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + }) + + it('removes runtime auth when restoring a no-login system default', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) const settings = createSettings({ codexManagedAccounts: [ { @@ -224,15 +301,93 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - settings.activeCodexManagedAccountId = null - writeFileSync(runtimeAuthPath, '{"account":"managed"}\n', 'utf-8') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') + + settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('clears an invalid active account selection and restores the system default snapshot', async () => { + it('removes runtime auth when deselecting with a missing system-default snapshot', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') + writeFileSync(runtimeAuthPath, managedAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + + it('removes runtime auth when deselecting with a corrupt system-default snapshot', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null + }) + const store = createStore(settings) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + + const snapshotPath = join( + testState.userDataDir, + 'codex-runtime-home', + 'system-default-auth.json' + ) + writeFileSync(snapshotPath, '{not valid json', 'utf-8') + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(existsSync(runtimeAuthPath)).toBe(false) + expect(existsSync(snapshotPath)).toBe(false) + expect(warnSpy).toHaveBeenCalledWith( + '[codex-runtime-home] Ignoring invalid system-default auth snapshot' + ) + }) + + it('clears an invalid active account selection and removes untrusted runtime auth', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const missingManagedHomePath = join( @@ -264,10 +419,25 @@ describe('CodexRuntimeHomeService', () => { new CodexRuntimeHomeService(store as never) expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) + it('clears an unknown active account id and removes untrusted runtime auth', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"stale-managed"}\n', 'utf-8') + const settings = createSettings({ + activeCodexManagedAccountId: 'missing-account' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + it('returns ~/.codex for Codex launch and rate-limit preparation', async () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') @@ -313,14 +483,16 @@ describe('CodexRuntimeHomeService', () => { updatedAt: 1, lastAuthenticatedAt: 1 } - ], - activeCodexManagedAccountId: 'account-1' + ] }) const store = createStore(settings) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + // Deselect managed account — should restore system default once settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() @@ -332,7 +504,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-tool"}\n') }) - it('restores system default on restart when persisted active account is invalid', async () => { + it('removes untrusted runtime auth on restart when persisted active account is invalid', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const settings = createSettings({ @@ -357,10 +529,8 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - // Constructor initializes lastSyncedAccountId='account-1' from settings, - // then syncForCurrentSelection finds missing auth.json and restores snapshot expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(existsSync(runtimeAuthPath)).toBe(false) expect(warnSpy).toHaveBeenCalled() }) @@ -429,6 +599,44 @@ describe('CodexRuntimeHomeService', () => { expect(mode).toBe(0o600) }) + it('tightens auth.json permissions when unchanged content is already present', async () => { + if (process.platform === 'win32') { + return + } + + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + chmodSync(runtimeAuthPath, 0o644) + service.syncForCurrentSelection() + + expect(statSync(runtimeAuthPath).mode & 0o777).toBe(0o600) + }) + it('does not throw when syncForCurrentSelection encounters an error', async () => { const store = createStore( createSettings({ @@ -505,11 +713,243 @@ describe('CodexRuntimeHomeService', () => { it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"tokens":"original"}\n' + const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') + const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Simulate CLI refreshing the token in ~/.codex/auth.json + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + + // Next sync should read back the refreshed token to managed storage + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('rejects runtime read-back from a different Codex identity', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') + const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'selected@example.com', + managedHomePath, + providerAccountId: 'acct-selected', + workspaceLabel: null, + workspaceAccountId: 'acct-selected', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Simulate an old live Codex PTY from another account refreshing the + // shared runtime auth after Orca has already selected account-1. + writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) + }) + + it('routes runtime read-back from a different Codex identity to its matching account', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') + const account1RefreshedAuth = createCodexAuthJson( + 'one@example.com', + 'acct-one', + 'one-refreshed' ) + const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedAuthPath1 = join(managedHomePath1, 'auth.json') + const managedAuthPath2 = join(managedHomePath2, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-one', + workspaceLabel: null, + workspaceAccountId: 'acct-one', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-two', + workspaceLabel: null, + workspaceAccountId: 'acct-two', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-2' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // An older account-1 Codex process refreshed the shared runtime file after + // Orca selected account-2. Persist the refresh to account-1, then restore + // the selected account in ~/.codex. + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2Auth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) + }) + + it('rejects ambiguous Codex read-back instead of choosing a managed account', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const originalAuth = createCodexAuthJson('same@example.com', 'acct-same', 'original') + const refreshedAuth = createCodexAuthJson('same@example.com', 'acct-same', 'refreshed') + const activeAuth = createCodexAuthJson('active@example.com', 'acct-active', 'active') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', originalAuth) + const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', activeAuth) + const managedAuthPath1 = join(managedHomePath1, 'auth.json') + const managedAuthPath2 = join(managedHomePath2, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'same@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'same@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + }, + { + id: 'account-3', + email: 'active@example.com', + managedHomePath: managedHomePath3, + providerAccountId: 'acct-active', + workspaceLabel: null, + workspaceAccountId: 'acct-active', + createdAt: 3, + updatedAt: 3, + lastAuthenticatedAt: 3 + } + ], + activeCodexManagedAccountId: 'account-3' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(originalAuth) + expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(originalAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(activeAuth) + }) + + it('rejects runtime read-back without a positive selected-account identity match', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') + const accountOnlyAuth = `${JSON.stringify({ + tokens: { + account_id: 'acct-stale', + refresh_token: 'stale' + } + })}\n` + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'selected@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, accountOnlyAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) + }) + + it('rejects same-email runtime read-back when account ids differ from sparse managed metadata', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const selectedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'selected') + const staleLivePtyAuth = createCodexAuthJson('user@example.com', 'acct-stale', 'stale') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) const managedAuthPath = join(managedHomePath, 'auth.json') const settings = createSettings({ codexManagedAccounts: [ @@ -532,19 +972,90 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Simulate CLI refreshing the token in ~/.codex/auth.json - writeFileSync(runtimeAuthPath, '{"tokens":"refreshed"}\n', 'utf-8') - - // Next sync should read back the refreshed token to managed storage + writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') service.syncForCurrentSelection() - expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"refreshed"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"refreshed"}\n') + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) }) - it('skips read-back on first sync after restart (lastWrittenAuthJson is null)', async () => { + it('reads back same-account refreshes for sparse managed metadata using stored auth identity', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const originalAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'original') + const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'refreshed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('reads back strong account-id refreshes when the runtime auth has no email', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') + const refreshedAuth = `${JSON.stringify({ + tokens: { + account_id: 'acct-1', + refresh_token: 'refreshed' + } + })}\n` + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('rejects unverifiable Codex read-back on first sync after restart', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - // Pre-populate runtime with different content to simulate a CLI refresh while Orca was down writeFileSync(runtimeAuthPath, '{"tokens":"refreshed-while-down"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, @@ -574,13 +1085,77 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - // After restart, managed storage should NOT be overwritten by unknown runtime state expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"original"}\n') - // Runtime should be overwritten with managed (conservative behavior) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"original"}\n') }) - it('skips read-back during account switch', async () => { + it('reads back verified same-account refreshes on first sync after restart', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original', 1_000) + const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed', 2_000) + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + ) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + }) + + it('rejects older same-account Codex auth on first sync after restart', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + const staleRuntimeAuth = createCodexAuthJson('user@example.com', 'acct-1', 'stale', 1_000) + const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed-newer', 2_000) + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + ) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(managedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) + }) + + it('does not contaminate the incoming Codex account during account switch', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') const managedHomePath1 = createManagedAuth( @@ -626,18 +1201,246 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Switch to account-2 — runtime still has account-1 tokens settings.activeCodexManagedAccountId = 'account-2' service.syncForCurrentSelection() - // Account-2's managed auth should NOT be contaminated with account-1's runtime tokens expect(readFileSync(managedAuthPath2, 'utf-8')).toBe('{"tokens":"account2"}\n') expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"account2"}\n') }) - it('detects external login on managed→system-default transition', async () => { + it('does not carry the reauth read-back skip across Codex account switches', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') + const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') + const account2RefreshedAuth = createCodexAuthJson( + 'two@example.com', + 'acct-two', + 'two-refreshed' + ) + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedAuthPath2 = join(managedHomePath2, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-one', + workspaceLabel: null, + workspaceAccountId: 'acct-one', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-two', + workspaceLabel: null, + workspaceAccountId: 'acct-two', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.clearLastWrittenAuthJson() + settings.activeCodexManagedAccountId = 'account-2' + service.syncForCurrentSelection() + + writeFileSync(runtimeAuthPath, account2RefreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2RefreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2RefreshedAuth) + }) + + it('does not apply inactive-account Codex reauth skip to the active account', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') + const account1RefreshedAuth = createCodexAuthJson( + 'one@example.com', + 'acct-one', + 'one-refreshed' + ) + const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedAuthPath1 = join(managedHomePath1, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-one', + workspaceLabel: null, + workspaceAccountId: 'acct-one', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-two', + workspaceLabel: null, + workspaceAccountId: 'acct-two', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + service.clearLastWrittenAuthJson('account-2') + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) + }) + + it('restores system default when unverified runtime auth appears before deselect', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + + // A stale or external process overwrites runtime with auth Orca cannot + // verify against the outgoing managed account. + writeFileSync(runtimeAuthPath, '{"account":"external-login"}\n', 'utf-8') + + // Deselect managed account + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect( + existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) + ).toBe(true) + }) + + it('restores system default after same-identity managed Codex refresh on deselect', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') + const externalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'external') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + + writeFileSync(runtimeAuthPath, externalAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(externalAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-old"}\n') + }) + + it('restores system default when stale Codex credentials are rejected on deselect', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') + const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') + const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'selected@example.com', + managedHomePath, + providerAccountId: 'acct-selected', + workspaceLabel: null, + workspaceAccountId: 'acct-selected', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + + writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-old"}\n') + }) + + it('keeps external Codex logout when deselecting managed account', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -664,30 +1467,21 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // External `codex auth login` overwrites runtime auth - writeFileSync(runtimeAuthPath, '{"account":"external-login"}\n', 'utf-8') - - // Deselect managed account + rmSync(runtimeAuthPath, { force: true }) settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() - // Should keep the external login, not restore stale system snapshot - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-login"}\n') - // Snapshot should be deleted so next capture picks up the external login - expect( - existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) - ).toBe(false) + expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + writeFileSync(runtimeAuthPath, '{"account":"system-1"}\n', 'utf-8') const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', - '{"tokens":"original"}\n' + '{"account":"managed"}\n' ) - const managedAuthPath = join(managedHomePath, 'auth.json') const settings = createSettings({ codexManagedAccounts: [ { @@ -709,16 +1503,105 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + writeFileSync(runtimeAuthPath, '{"account":"system-2"}\n', 'utf-8') + + settings.activeCodexManagedAccountId = 'account-1' + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-2"}\n') + }) + + it('reads back refreshed tokens for the outgoing Codex account before switching', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const account1Original = createCodexAuthJson('one@example.com', 'acct-1', 'one-original') + const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed') + const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Original) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedAuthPath1 = join(managedHomePath1, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-2', + workspaceLabel: null, + workspaceAccountId: 'acct-2', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8') + settings.activeCodexManagedAccountId = 'account-2' + service.syncForCurrentSelection() + + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1Refreshed) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) + }) + + it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') + const reauthedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'reauthed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + // Simulate re-auth: managed storage gets fresh tokens - writeFileSync(managedAuthPath, '{"tokens":"reauthed"}\n', 'utf-8') + writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') // Clear tracking before sync (as CodexAccountService would) service.clearLastWrittenAuthJson() service.syncForCurrentSelection() // Fresh re-auth tokens should survive — not be clobbered by stale runtime read-back - expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"reauthed"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"reauthed"}\n') + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) }) it('preserves conflicting legacy session files under deterministic names', async () => { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 3ec95ecfa68..21866294248 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -5,6 +5,7 @@ semantics from drifting across PTY launch, login, and quota fetch paths. */ import { copyFileSync, existsSync, + chmodSync, mkdirSync, readdirSync, readFileSync, @@ -18,6 +19,26 @@ import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' import { writeFileAtomically } from './fs-utils' +type CodexAuthIdentity = { + email: string | null + providerAccountId: string | null + workspaceAccountId: string | null +} + +type CodexSystemDefaultSnapshot = { + authJson: string | null +} + +type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' +type CodexReadBackMatch = + | { + kind: 'matched' + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + } + | { kind: 'none' | 'ambiguous' } + export class CodexRuntimeHomeService { // Why: tracks whether auth.json is currently managed by Orca. When null, // Orca does NOT own auth.json and must not overwrite external changes @@ -31,6 +52,7 @@ 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 + private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { this.safeMigrateLegacyManagedState() @@ -44,31 +66,47 @@ export class CodexRuntimeHomeService { } prepareForCodexLaunch(): string { - this.safeSyncForCurrentSelection() + this.syncForCurrentSelection() return this.getRuntimeHomePath() } prepareForRateLimitFetch(): string { - this.safeSyncForCurrentSelection() + this.syncForCurrentSelection() return this.getRuntimeHomePath() } syncForCurrentSelection(): void { - this.captureSystemDefaultSnapshotIfNeeded() - const settings = this.store.getSettings() + if (this.lastSyncedAccountId === null) { + this.captureSystemDefaultSnapshot({ force: false }) + } const activeAccount = this.getActiveAccount( settings.codexManagedAccounts, settings.activeCodexManagedAccountId ) + const previousAccount = this.getActiveAccount( + settings.codexManagedAccounts, + this.lastSyncedAccountId + ) + let outgoingReadBackResult: CodexReadBackResult = 'unchanged' + if (previousAccount && previousAccount.id !== activeAccount?.id) { + outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, { + updateLastWrittenAuthJson: true + }) + } if (!activeAccount) { + if (settings.activeCodexManagedAccountId) { + this.store.updateSettings({ activeCodexManagedAccountId: null }) + } // Why: only restore the snapshot when transitioning FROM a managed // account back to system default. When no managed account was ever // active, auth.json belongs to the user and Orca must not touch it. // This prevents overwriting external auth changes (codex login or other // tools) on every PTY launch / rate-limit fetch. if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot() + this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult !== 'rejected' + }) this.lastSyncedAccountId = null } return @@ -81,20 +119,33 @@ export class CodexRuntimeHomeService { ) this.store.updateSettings({ activeCodexManagedAccountId: null }) if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot() + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) this.lastSyncedAccountId = null } return } + if (this.lastSyncedAccountId === null) { + this.captureSystemDefaultSnapshot({ force: true }) + } + // Why: Codex CLI refreshes expired OAuth tokens and writes them back to // ~/.codex/auth.json. If we detect the runtime file differs from what Orca // last wrote, the CLI must have refreshed — so we preserve those tokens // back to managed storage before overwriting runtime with managed state. if (this.lastSyncedAccountId === activeAccount.id) { - this.readBackRefreshedTokens(activeAuthPath) + if (this.skipNextReadBackForAccountId === activeAccount.id) { + this.skipNextReadBackForAccountId = null + } else { + this.readBackRefreshedTokens({ + updateLastWrittenAuthJson: true + }) + } } + if (this.lastSyncedAccountId !== activeAccount.id) { + this.skipNextReadBackForAccountId = null + } this.lastSyncedAccountId = activeAccount.id this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8')) } @@ -103,35 +154,64 @@ 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(): void { - this.lastWrittenAuthJson = null + clearLastWrittenAuthJson(accountId = this.store.getSettings().activeCodexManagedAccountId): void { + if (accountId === this.store.getSettings().activeCodexManagedAccountId) { + this.lastWrittenAuthJson = null + } + this.skipNextReadBackForAccountId = accountId } - private readBackRefreshedTokens(managedAuthPath: string): void { + private readBackRefreshedTokens(options: { + updateLastWrittenAuthJson: boolean + }): CodexReadBackResult { try { const runtimeAuthPath = this.getRuntimeAuthPath() if (!existsSync(runtimeAuthPath)) { - return - } - - if (this.lastWrittenAuthJson === null) { - return + return 'unchanged' } const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (runtimeContents === this.lastWrittenAuthJson) { - return + if (this.lastWrittenAuthJson !== null && runtimeContents === this.lastWrittenAuthJson) { + return 'unchanged' } - writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 }) + const match = this.findManagedAccountForRuntimeAuth(runtimeContents) + if (match.kind !== 'matched') { + if (match.kind === 'ambiguous') { + console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') + } + return 'rejected' + } + // 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 && + !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) + ) { + return 'rejected' + } + + writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) + if (options.updateLastWrittenAuthJson) { + this.lastWrittenAuthJson = runtimeContents + } + return 'persisted' } catch (error) { // Why: read-back is best-effort. A transient fs error must not block the // forward sync path — the worst case is one more stale-token cycle, which // is strictly better than failing the entire sync. console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) + return 'rejected' } } + private readBackRefreshedTokensForAccount( + _account: CodexManagedAccount, + options: { updateLastWrittenAuthJson: boolean } + ): CodexReadBackResult { + return this.readBackRefreshedTokens(options) + } + private safeSyncForCurrentSelection(): void { try { this.syncForCurrentSelection() @@ -150,6 +230,208 @@ export class CodexRuntimeHomeService { return accounts.find((account) => account.id === activeAccountId) ?? null } + private findManagedAccountForRuntimeAuth(runtimeAuthContents: string): CodexReadBackMatch { + const matches: { + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + }[] = [] + for (const account of this.store.getSettings().codexManagedAccounts) { + const managedAuthPath = join(account.managedHomePath, 'auth.json') + if (!existsSync(managedAuthPath)) { + continue + } + const managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + if (this.runtimeAuthMatchesAccount(runtimeAuthContents, account, managedAuthContents)) { + matches.push({ account, managedAuthPath, managedAuthContents }) + } + } + + if (matches.length === 1) { + return { kind: 'matched', ...matches[0] } + } + return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + } + + private runtimeAuthMatchesAccount( + runtimeAuthContents: string, + activeAccount: CodexManagedAccount, + managedAuthContents: string + ): boolean { + const identity = this.readIdentityFromAuthContents(runtimeAuthContents) + if (!identity) { + return false + } + const managedIdentity = this.readIdentityFromAuthContents(managedAuthContents) + + // Why: old live Codex PTYs can still write refreshed tokens into the + // shared runtime home after the user switches accounts. Never persist + // that write into the newly active managed account unless the auth claims + // still match the account Orca believes is selected. + const selectedEmail = this.firstNonNull( + this.normalizeField(activeAccount.email), + managedIdentity?.email + ) + const selectedProviderId = this.firstNonNull( + this.normalizeField(activeAccount.providerAccountId), + managedIdentity?.providerAccountId + ) + const selectedWorkspaceId = this.firstNonNull( + this.normalizeField(activeAccount.workspaceAccountId), + managedIdentity?.workspaceAccountId + ) + const emailMatches = Boolean( + selectedEmail && identity.email && selectedEmail === identity.email + ) + if (selectedEmail && identity.email && selectedEmail !== identity.email) { + return false + } + if (!this.identityFieldMatches(selectedProviderId, identity.providerAccountId)) { + return false + } + if (!this.identityFieldMatches(selectedWorkspaceId, identity.workspaceAccountId)) { + return false + } + + const hasStrongIdentity = Boolean( + (selectedProviderId && identity.providerAccountId) || + (selectedWorkspaceId && identity.workspaceAccountId) + ) + return ( + hasStrongIdentity || + (emailMatches && !identity.providerAccountId && !identity.workspaceAccountId) + ) + } + + private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { + const runtimeFreshness = this.readFreshnessFromAuthContents(runtimeAuthContents) + const managedFreshness = this.readFreshnessFromAuthContents(managedAuthContents) + return ( + runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness + ) + } + + private identityFieldMatches(selectedField: string | null, runtimeField: string | null): boolean { + return !selectedField || Boolean(runtimeField && selectedField === runtimeField) + } + + private firstNonNull(...values: (string | null | undefined)[]): string | null { + return values.find((value): value is string => Boolean(value)) ?? null + } + + private readIdentityFromAuthContents(contents: string): CodexAuthIdentity | null { + let raw: Record + try { + raw = JSON.parse(contents) as Record + } catch { + return null + } + + const tokens = this.readRecordClaim(raw, 'tokens') + const idToken = this.normalizeField( + this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken') + ) + const payload = idToken ? this.parseJwtPayload(idToken) : null + const authClaims = this.readRecordClaim(payload, 'https://api.openai.com/auth') + const profileClaims = this.readRecordClaim(payload, 'https://api.openai.com/profile') + + return { + email: this.normalizeField( + this.readStringClaim(payload, 'email') ?? this.readStringClaim(profileClaims, 'email') + ), + providerAccountId: this.normalizeField( + this.readStringClaim(tokens, 'account_id') ?? + this.readStringClaim(tokens, 'accountId') ?? + this.readStringClaim(authClaims, 'chatgpt_account_id') ?? + this.readStringClaim(payload, 'chatgpt_account_id') + ), + workspaceAccountId: this.normalizeField( + this.readStringClaim(authClaims, 'workspace_account_id') ?? + this.readStringClaim(tokens, 'account_id') ?? + this.readStringClaim(tokens, 'accountId') ?? + this.readStringClaim(payload, 'chatgpt_account_id') + ) + } + } + + private readFreshnessFromAuthContents(contents: string): number | null { + let raw: Record + try { + raw = JSON.parse(contents) as Record + } catch { + return null + } + + const tokens = this.readRecordClaim(raw, 'tokens') + const idToken = this.normalizeField( + this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken') + ) + const payload = idToken ? this.parseJwtPayload(idToken) : null + return ( + this.readNumberClaim(tokens, 'expires_at') ?? + this.readNumberClaim(tokens, 'expiresAt') ?? + this.readNumberClaim(tokens, 'expiry') ?? + this.readNumberClaim(tokens, 'expires') ?? + this.readNumberClaim(payload, 'exp') ?? + this.readNumberClaim(payload, 'iat') + ) + } + + private parseJwtPayload(token: string): Record | null { + const parts = token.split('.') + if (parts.length < 2) { + return null + } + + let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') + while (payload.length % 4 !== 0) { + payload += '=' + } + + try { + const json = Buffer.from(payload, 'base64').toString('utf-8') + return JSON.parse(json) as Record + } catch { + return null + } + } + + private readRecordClaim( + value: Record | null, + key: string + ): Record | null { + const claim = value?.[key] + if (!claim || typeof claim !== 'object' || Array.isArray(claim)) { + return null + } + return claim as Record + } + + private readStringClaim(value: Record | null, key: string): string | null { + const claim = value?.[key] + return typeof claim === 'string' ? claim : null + } + + private readNumberClaim(value: Record | null, key: string): number | null { + const claim = value?.[key] + if (typeof claim === 'number' && Number.isFinite(claim)) { + return claim + } + if (typeof claim === 'string') { + const parsed = Number(claim) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + + private normalizeField(value: string | null | undefined): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed === '' ? null : trimmed + } + private safeMigrateLegacyManagedState(): void { try { this.migrateLegacyManagedStateIfNeeded() @@ -330,34 +612,48 @@ export class CodexRuntimeHomeService { writeFileAtomically(diagnosticsPath, `${existingContents}${JSON.stringify(record)}\n`) } - private captureSystemDefaultSnapshotIfNeeded(): void { + private captureSystemDefaultSnapshot(options: { force: boolean }): void { const snapshotPath = this.getSystemDefaultSnapshotPath() - if (existsSync(snapshotPath)) { + if (!options.force && existsSync(snapshotPath)) { return } const runtimeAuthPath = this.getRuntimeAuthPath() - if (!existsSync(runtimeAuthPath)) { - return + const snapshot: CodexSystemDefaultSnapshot = { + authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null } - - writeFileAtomically(snapshotPath, readFileSync(runtimeAuthPath, 'utf-8')) + writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) } - private restoreSystemDefaultSnapshot(): void { + private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { // Why: detect whether an external tool (e.g. `codex auth login`) overwrote // auth.json while a managed account was active. If so, that external login // becomes the new system default — skip the stale snapshot restore. - if (this.detectExternalLoginAndUpdateSnapshot()) { + if (options.detectExternalLogin && this.detectExternalLoginAndUpdateSnapshot()) { return } const snapshotPath = this.getSystemDefaultSnapshotPath() if (!existsSync(snapshotPath)) { + rmSync(this.getRuntimeAuthPath(), { force: true }) + this.lastWrittenAuthJson = null return } - this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8')) + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!snapshot) { + console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') + rmSync(snapshotPath, { force: true }) + rmSync(this.getRuntimeAuthPath(), { force: true }) + this.lastWrittenAuthJson = null + return + } + if (snapshot.authJson === null) { + rmSync(this.getRuntimeAuthPath(), { force: true }) + this.lastWrittenAuthJson = null + return + } + this.writeRuntimeAuth(snapshot.authJson) } // Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot(). @@ -371,7 +667,10 @@ export class CodexRuntimeHomeService { const runtimeAuthPath = this.getRuntimeAuthPath() if (!existsSync(runtimeAuthPath)) { - return false + const snapshotPath = this.getSystemDefaultSnapshotPath() + rmSync(snapshotPath, { force: true }) + this.lastWrittenAuthJson = null + return true } try { @@ -392,10 +691,52 @@ export class CodexRuntimeHomeService { private writeRuntimeAuth(contents: string): void { // Why: auth.json contains sensitive credentials. Restrict to owner-only // so other users on a shared Linux/macOS machine cannot read it. + if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) { + this.ensureOwnerOnlyMode(this.getRuntimeAuthPath()) + this.lastWrittenAuthJson = contents + return + } writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 }) this.lastWrittenAuthJson = contents } + private fileContentsEqual(targetPath: string, contents: string): boolean { + try { + return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents + } catch { + return false + } + } + + private ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } + } + + private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { + try { + const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + 'authJson' in parsed && + (typeof parsed.authJson === 'string' || parsed.authJson === null) + ) { + return parsed as CodexSystemDefaultSnapshot + } + } catch { + return null + } + return null + } + clearSystemDefaultSnapshot(): void { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index d3673d56681..3538ec69e28 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -24,6 +24,7 @@ vi.mock('node:os', async () => { }) function createSettings(overrides: Partial = {}): GlobalSettings { + const appFontFamily = overrides.appFontFamily ?? 'Geist' return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -59,14 +60,15 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalScrollbackBytes: 10_000_000, openLinksInApp: false, rightSidebarOpenByDefault: true, - showTitlebarAgentActivity: true, + showTitlebarAppName: true, showTasksButton: true, diffDefaultView: 'inline', notifications: { enabled: true, agentTaskComplete: true, terminalBell: false, - suppressWhenFocused: true + suppressWhenFocused: true, + customSoundPath: null }, promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, @@ -87,12 +89,16 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, - experimentalAgentDashboard: false, - experimentalSidekick: false, + experimentalMobile: false, + mobileAutoRestoreFitMs: null, + experimentalPet: false, + experimentalActivity: false, + experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, - ...overrides + ...overrides, + appFontFamily } } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 2fcaa742c3f..8b6306e24ff 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -106,7 +106,7 @@ export class CodexAccountService { activeCodexManagedAccountId: account.id }) this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.clearLastWrittenAuthJson() + this.runtimeHome.clearLastWrittenAuthJson(account.id) this.runtimeHome.syncForCurrentSelection() // Why: the new account becomes active, so the previous active account is @@ -150,7 +150,7 @@ export class CodexAccountService { codexManagedAccounts: updatedAccounts }) this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.clearLastWrittenAuthJson() + this.runtimeHome.clearLastWrittenAuthJson(accountId) this.runtimeHome.syncForCurrentSelection() // Why: re-auth can change which actual Codex identity the managed home diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 5b9b0b0fa6a..da0549d2387 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -164,6 +164,112 @@ describe('CodexUsageStore', () => { expect(summary.reasoningOutputTokens).toBe(100) }) + it('prices current Codex models with current model rates', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'gpt-5.2-codex', + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 2_000_000, + cachedInputTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningOutputTokens: 100_000, + totalTokens: 3_000_000, + hasInferredPricing: false + }, + { + day: '2026-04-09', + model: 'gpt-5.3-codex', + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 2_000_000, + cachedInputTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningOutputTokens: 100_000, + totalTokens: 3_000_000, + hasInferredPricing: false + }, + { + day: '2026-04-09', + model: 'gpt-5.4', + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 2_000_000, + cachedInputTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningOutputTokens: 100_000, + totalTokens: 3_000_000, + hasInferredPricing: false + }, + { + day: '2026-04-09', + model: 'gpt-5.5', + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 2_000_000, + cachedInputTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningOutputTokens: 100_000, + totalTokens: 3_000_000, + hasInferredPricing: false + } + ] + }) + + const summary = await store.getSummary('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(summary.estimatedCostUsd).toBeCloseTo(85.1) + expect(breakdown.find((row) => row.key === 'gpt-5.2-codex')?.estimatedCostUsd).toBeCloseTo( + 15.925 + ) + expect(breakdown.find((row) => row.key === 'gpt-5.3-codex')?.estimatedCostUsd).toBeCloseTo( + 15.925 + ) + expect(breakdown.find((row) => row.key === 'gpt-5.4')?.estimatedCostUsd).toBeCloseTo(17.75) + expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(35.5) + }) + + it('keeps cached input out of the full-price input bucket for GPT-5.5 totals', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'gpt-5.5', + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 491_053_514, + cachedInputTokens: 459_283_584, + outputTokens: 1_944_952, + reasoningOutputTokens: 551_764, + totalTokens: 492_998_466, + hasInferredPricing: false + } + ] + }) + + const summary = await store.getSummary('orca', '30d') + + expect(summary.estimatedCostUsd).toBeCloseTo(446.840002) + }) + it('counts mixed-model sessions once for each real model row in the breakdown', async () => { const store = createStoreWithState({ sessions: [ diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index a2f98de2b96..dba5c4165a0 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -2,7 +2,6 @@ import { app } from 'electron' import { dirname, join } from 'path' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' -import type { Repo } from '../../shared/types' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, @@ -13,11 +12,10 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../../shared/codex-usage-types' -import { listRepoWorktrees } from '../repo-worktrees' import type { Store } from '../persistence' -import { mergeWorktree } from '../ipc/worktree-logic' +import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' -import { createWorktreeRefs, getDefaultWorktreeLabel, scanCodexUsageFiles } from './scanner' +import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' const SCHEMA_VERSION = 2 const STALE_MS = 5 * 60_000 @@ -26,8 +24,11 @@ let _codexUsageFile: string | null = null const MODEL_PRICING: Record = { 'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 }, - 'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 }, - 'gpt-5.3-codex': { input: 1.9, cachedInput: 0.19, output: 15 } + 'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 }, + 'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.4': { input: 2.5, cachedInput: 0.25, output: 15 }, + 'gpt-5.5': { input: 5, cachedInput: 0.5, output: 30 } } function getDefaultState(): CodexUsagePersistedState { @@ -80,15 +81,24 @@ function normalizeModelForPricing(model: string | null): string | null { } const lower = model.toLowerCase() - if (lower === 'gpt-5' || lower === 'gpt-5-codex' || lower.startsWith('gpt-5.4')) { + if (lower === 'gpt-5' || lower === 'gpt-5-codex') { return 'gpt-5' } - if (lower.startsWith('gpt-5.2-codex')) { - return 'gpt-5.2-codex' + if (lower.startsWith('gpt-5.1')) { + return 'gpt-5.1' + } + if (lower.startsWith('gpt-5.2')) { + return 'gpt-5.2' } if (lower.startsWith('gpt-5.3-codex')) { return 'gpt-5.3-codex' } + if (lower.startsWith('gpt-5.4')) { + return 'gpt-5.4' + } + if (lower.startsWith('gpt-5.5')) { + return 'gpt-5.5' + } return null } @@ -141,12 +151,6 @@ function getLocalDay(timestamp: string): string | null { return `${year}-${month}-${day}` } -type CodexUsageWorktree = { - worktreeId: string - path: string - displayName: string -} - type ScopedCodexUsageModelRow = { modelKey: string modelLabel: string @@ -159,7 +163,7 @@ type ScopedCodexUsageModelRow = { totalTokens: number } -function getWorktreeFingerprint(worktreesByRepo: Map): string { +function getWorktreeFingerprint(worktreesByRepo: Map): string { const rows = [...worktreesByRepo.entries()] .flatMap(([repoId, worktrees]) => worktrees.map((worktree) => @@ -259,7 +263,7 @@ export class CodexUsageStore { this.scanPromise = (async () => { try { const repos = this.store.getRepos() - const worktreesByRepo = await this.loadWorktreesByRepo(repos) + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) const result = await scanCodexUsageFiles( createWorktreeRefs(repos, worktreesByRepo), @@ -591,28 +595,7 @@ export class CodexUsageStore { private async getCurrentWorktreeFingerprint(): Promise { const repos = this.store.getRepos() - const worktreesByRepo = await this.loadWorktreesByRepo(repos) + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) return getWorktreeFingerprint(worktreesByRepo) } - - private async loadWorktreesByRepo(repos: Repo[]): Promise> { - const worktreesByRepo = new Map() - - for (const repo of repos) { - const gitWorktrees = await listRepoWorktrees(repo) - const mapped = gitWorktrees.map((worktree) => { - const worktreeId = `${repo.id}::${worktree.path}` - const meta = this.store.getWorktreeMeta(worktreeId) - const merged = mergeWorktree(repo.id, worktree, meta, repo.displayName) - return { - worktreeId, - path: worktree.path, - displayName: merged.displayName || getDefaultWorktreeLabel(worktree.path) - } - }) - worktreesByRepo.set(repo.id, mapped) - } - - return worktreesByRepo - } } diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts new file mode 100644 index 00000000000..45c315585d4 --- /dev/null +++ b/src/main/codex/config-toml-trust.test.ts @@ -0,0 +1,973 @@ +/* eslint-disable max-lines -- Why: this suite keeps the hash fixture, TOML edit edge cases, and trust-state parser regressions together so Codex compatibility failures are easy to audit. */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + computeTrustKey, + computeTrustedHash, + parseTrustKey, + readHookTrustEntries, + removeHookTrustEntries, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' + +// Why: this hash was captured from a real Codex 0.129 `/hooks` approval. If +// Codex changes its serialization or normalization rules, this test fails +// loudly instead of silently shipping bad trust entries that put hooks back +// into the review pile. +const REAL_APPROVED_COMMAND = '/bin/sh "/tmp/orca-case-b-mCmCe6/agent-hooks/codex-hook.sh"' +const REAL_APPROVED_HASH = 'sha256:bc013489dba495431d3790fda62ee5a7d907a7c491e29ad26238c3a5d6d2b163' + +let tmpDir: string +let configPath: string + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-codex-trust-test-')) + configPath = join(tmpDir, 'config.toml') +}) + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('computeTrustedHash', () => { + it('reproduces the hash that Codex /hooks wrote for a real approval', () => { + expect( + computeTrustedHash({ + sourcePath: '/Users/thebr/.codex/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: REAL_APPROVED_COMMAND + }) + ).toBe(REAL_APPROVED_HASH) + }) + + it('produces a different hash when the command changes', () => { + const a = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + }) + const b = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'bar' + }) + expect(a).not.toBe(b) + }) + + it('produces a different hash when the event label changes', () => { + const a = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + }) + const b = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'post_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + }) + expect(a).not.toBe(b) + }) + + it('ignores groupIndex/handlerIndex (those are part of the key, not the hash)', () => { + const a = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + }) + const b = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 99, + handlerIndex: 99, + command: 'foo' + }) + expect(a).toBe(b) + }) + + it('hashes a missing matcher the same as no matcher field', () => { + const a = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + }) + const b = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo', + matcher: undefined + }) + expect(a).toBe(b) + }) + + it('produces a different hash when matcher is set', () => { + const a = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + }) + const b = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo', + matcher: 'foo' + }) + expect(a).not.toBe(b) + }) + + it('produces a different hash when statusMessage is set', () => { + const a = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + }) + const b = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo', + statusMessage: 'msg' + }) + expect(a).not.toBe(b) + }) + + it('produces a different hash when async flips from default false to true', () => { + const a = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo', + async: false + }) + const b = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo', + async: true + }) + expect(a).not.toBe(b) + }) + + it('clamps timeoutSec=0 to 1 (which differs from the unset default of 600)', () => { + const zero = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo', + timeoutSec: 0 + }) + const one = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo', + timeoutSec: 1 + }) + const unset = computeTrustedHash({ + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + }) + expect(zero).toBe(one) + expect(zero).not.toBe(unset) + }) +}) + +describe('computeTrustKey', () => { + it('joins source path, event label, group index, handler index with colons', () => { + expect( + computeTrustKey({ + sourcePath: '/Users/thebr/.codex/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'irrelevant' + }) + ).toBe('/Users/thebr/.codex/hooks.json:pre_tool_use:0:0') + }) +}) + +describe('upsertHookTrustEntries', () => { + it('creates the file with a trust block when none exists', () => { + const entry: CodexTrustEntry = { + sourcePath: '/foo/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: '/bin/echo hi' + } + upsertHookTrustEntries(configPath, [entry]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain(`[hooks.state."/foo/hooks.json:pre_tool_use:0:0"]`) + expect(written).toContain('enabled = true') + expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) + }) + + it('appends to an existing config without disturbing prior content', () => { + const original = [ + 'model = "gpt-5.5"', + 'approval_policy = "never"', + '', + '[features]', + 'hooks = true', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: 'echo hello' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written.startsWith(original.trimEnd())).toBe(true) + expect(written).toContain('[hooks.state."/x/hooks.json:session_start:0:0"]') + }) + + it('replaces an existing block keyed at the same path without touching unrelated blocks', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + '[features]', + 'hooks = true', + '', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE"', + '', + '[unrelated]', + 'value = 42', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo new' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain('STALE') + expect(written).toContain('[unrelated]') + expect(written).toContain('value = 42') + // Why: we only own the [hooks.state.""] block — the [features] + // block must be unchanged. + expect(written).toContain('[features]\nhooks = true') + }) + + it('writes a single block per entry even when called repeatedly', () => { + const entry: CodexTrustEntry = { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + upsertHookTrustEntries(configPath, [entry]) + upsertHookTrustEntries(configPath, [entry]) + upsertHookTrustEntries(configPath, [entry]) + + const written = readFileSync(configPath, 'utf-8') + const occurrences = written.match(/\[hooks\.state\./g) ?? [] + expect(occurrences).toHaveLength(1) + }) + + it('writes a .bak file before overwriting an existing config', () => { + writeFileSync(configPath, 'model = "old"\n', 'utf-8') + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + ]) + expect(existsSync(`${configPath}.bak`)).toBe(true) + expect(readFileSync(`${configPath}.bak`, 'utf-8')).toBe('model = "old"\n') + }) + + it('does not write at all when the file already has the right hash', () => { + const entry: CodexTrustEntry = { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + upsertHookTrustEntries(configPath, [entry]) + const firstWrite = readFileSync(configPath, 'utf-8') + // Why: a no-op upsert must not roll the .bak forward — repeated calls + // (e.g. from app start) would otherwise destroy the last recoverable copy. + rmSync(`${configPath}.bak`, { force: true }) + upsertHookTrustEntries(configPath, [entry]) + expect(existsSync(`${configPath}.bak`)).toBe(false) + expect(readFileSync(configPath, 'utf-8')).toBe(firstWrite) + }) + + it('replaces a stale block written with CRLF line endings without duplicating', () => { + // Why: regression — Windows-style \r\n in the existing config previously + // caused the header pattern to miss and append a duplicate block. + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + '[features]', + 'hooks = true', + '', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE"', + '' + ].join('\r\n') + writeFileSync(configPath, original, 'utf-8') + + const entry: CodexTrustEntry = { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo new' + } + upsertHookTrustEntries(configPath, [entry]) + + const written = readFileSync(configPath, 'utf-8') + const occurrences = written.match(/\[hooks\.state\./g) ?? [] + expect(occurrences).toHaveLength(1) + expect(written).not.toContain('STALE') + expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`) + }) + + it('preserves an immediately-adjacent unrelated hooks.state block', () => { + const targetKey = '/x/hooks.json:pre_tool_use:0:0' + const neighborKey = '/y/hooks.json:post_tool_use:0:0' + const original = [ + `[hooks.state."${targetKey}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE"', + `[hooks.state."${neighborKey}"]`, + 'enabled = true', + 'trusted_hash = "sha256:NEIGHBOR"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo new' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain('STALE') + expect(written).toContain(`[hooks.state."${neighborKey}"]`) + expect(written).toContain('trusted_hash = "sha256:NEIGHBOR"') + // Neighbor's `enabled = true` should still be paired with NEIGHBOR's hash. + const neighborIdx = written.indexOf(`[hooks.state."${neighborKey}"]`) + expect(written.slice(neighborIdx)).toMatch(/enabled = true[\s\S]*sha256:NEIGHBOR/) + }) + + it('preserves an unrelated table whose quoted key contains a `]`', () => { + const original = ['[other."a]b"]', 'foo = 1', ''].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain('[other."a]b"]') + expect(written).toContain('foo = 1') + }) + + // Why: TOML supports both basic-string and literal-string quoted keys; + // header detection must respect `]` inside `'...'` too. + it('preserves an unrelated table whose literal-string key contains a `]`', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE"', + "[other.'a]b']", + 'foo = 1', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo new' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain('STALE') + expect(written).toContain("[other.'a]b']") + expect(written).toContain('foo = 1') + }) + + it('does not treat `[fake]` inside a multi-line basic string as a header', () => { + const original = [ + 'model = "gpt"', + 'description = """', + 'This text has a fake header:', + '[fake]', + 'inside it.', + '"""', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain( + ['description = """', 'This text has a fake header:', '[fake]', 'inside it.', '"""'].join( + '\n' + ) + ) + }) + + it('treats `\\"""` inside a multi-line basic string as an escaped quote, not a close', () => { + // Why: a basic multi-line string with `\"` escapes must not be misread as + // closing early — content and any following real header must survive intact. + const original = [ + 'prompt = """', + 'use \\"\\"\\" carefully', + '"""', + '', + '[other]', + 'x = 1', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain(['prompt = """', 'use \\"\\"\\" carefully', '"""'].join('\n')) + expect(written).toContain('[other]\nx = 1') + expect(written).toContain('[hooks.state."/x/hooks.json:pre_tool_use:0:0"]') + }) + + it('escapes literal `"` and `\\` in the source path inside the trust block header', () => { + const entry: CodexTrustEntry = { + sourcePath: '/x/with"quote\\and\\back/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + upsertHookTrustEntries(configPath, [entry]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain( + `[hooks.state."/x/with\\"quote\\\\and\\\\back/hooks.json:pre_tool_use:0:0"]` + ) + }) + + it('overwrites an existing block whose header has leading whitespace (TOML allows indent)', () => { + // Why: regression — buildHeaderPattern used to require column-0 headers, + // but the reader accepts indented ones. That mismatch caused upsert to + // append a duplicate `[hooks.state.""]` block, producing invalid TOML. + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = ` [hooks.state."${key}"]\nenabled = true\ntrusted_hash = "sha256:OLD"\n` + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo hi' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + const headerCount = (written.match(/\[hooks\.state\."/g) ?? []).length + expect(headerCount).toBe(1) + expect(written).not.toContain('sha256:OLD') + }) + + it('preserves `enabled = false` when the user hand-edited it before reinstall', () => { + // Why: regression — auto-install on app start used to clobber a + // hand-disabled hook back to enabled = true, removing the only way to + // mute Orca's hook short of full uninstall. + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = `[hooks.state."${key}"]\nenabled = false\ntrusted_hash = "sha256:OLD"\n` + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo hi' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).toContain('enabled = false') + expect(written).not.toContain('enabled = true') + }) + + it('overwrites an existing block when the file ends without a trailing newline', () => { + // Why: regression — buildHeaderPattern used to require a trailing + // `\r?\n`, missing it caused the upsert path to take the no-match branch + // and append a duplicate block at EOF. + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = `[hooks.state."${key}"]\nenabled = true\ntrusted_hash = "sha256:OLD"` + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo hi' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + const headerCount = (written.match(/\[hooks\.state\."/g) ?? []).length + expect(headerCount).toBe(1) + expect(written).not.toContain('sha256:OLD') + }) + + it('overwrites an existing block whose header has an inline comment', () => { + // Why: regression — buildHeaderPattern used to require the header line + // to end at \r?\n or EOF, missing TOML-valid trailing comments. The + // upsert path then took the no-match branch and appended a duplicate + // `[hooks.state.""]` block, producing invalid TOML. + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:OLD"\n` + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo hi' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + const headerCount = (written.match(/\[hooks\.state\."/g) ?? []).length + expect(headerCount).toBe(1) + expect(written).not.toContain('sha256:OLD') + }) +}) + +describe('removeHookTrustEntries', () => { + it('is a no-op (creates no file) when the config does not exist', () => { + removeHookTrustEntries(configPath, ['/x/hooks.json:pre_tool_use:0:0']) + expect(existsSync(configPath)).toBe(false) + }) + + it('does not roll a .bak forward when the requested key is not present', () => { + const original = ['[features]', 'hooks = true', ''].join('\n') + writeFileSync(configPath, original, 'utf-8') + removeHookTrustEntries(configPath, ['/missing/hooks.json:pre_tool_use:0:0']) + expect(readFileSync(configPath, 'utf-8')).toBe(original) + expect(existsSync(`${configPath}.bak`)).toBe(false) + }) + + it('removes a single block while leaving unrelated tables intact', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + '[features]', + 'hooks = true', + '', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:KEEP"', + '', + '[unrelated]', + 'value = 42', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [key]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain(`[hooks.state."${key}"]`) + expect(written).not.toContain('sha256:KEEP') + expect(written).toContain('[features]\nhooks = true') + expect(written).toContain('[unrelated]\nvalue = 42') + }) + + it('preserves the line separator when no blank line precedes the removed block', () => { + // Why: regression — removeTrustBlock used to cut from match.index (the + // captured leading newline) and fused the previous content into the next + // header, producing invalid TOML like `a = 1[other]`. + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + 'a = 1', + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:K"', + '[other]', + 'b = 2', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [key]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain('a = 1[other]') + expect(written).toContain('a = 1\n[other]') + }) + + it('removes multiple blocks in a single call', () => { + const keyA = '/x/hooks.json:pre_tool_use:0:0' + const keyB = '/x/hooks.json:post_tool_use:0:0' + const original = [ + `[hooks.state."${keyA}"]`, + 'enabled = true', + 'trusted_hash = "sha256:A"', + '', + `[hooks.state."${keyB}"]`, + 'enabled = true', + 'trusted_hash = "sha256:B"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [keyA, keyB]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain(`[hooks.state."${keyA}"]`) + expect(written).not.toContain(`[hooks.state."${keyB}"]`) + expect(written).not.toContain('sha256:A') + expect(written).not.toContain('sha256:B') + }) + + it('removes a block whose header has an inline comment', () => { + // Why: paired with the upsert regression; the same pattern mismatch + // would silently leave the dead block in place during uninstall. + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:K"\n` + writeFileSync(configPath, original, 'utf-8') + + removeHookTrustEntries(configPath, [key]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain(`[hooks.state."${key}"]`) + }) +}) + +describe('readHookTrustEntries', () => { + it('returns an empty map when the file does not exist', () => { + const result = readHookTrustEntries(configPath) + expect(result.size).toBe(0) + }) + + it('returns key→hash entries for each [hooks.state.""] block', () => { + const keyA = '/x/hooks.json:pre_tool_use:0:0' + const keyB = '/y/hooks.json:post_tool_use:1:0' + const original = [ + `[hooks.state."${keyA}"]`, + 'enabled = true', + 'trusted_hash = "sha256:AAA"', + '', + `[hooks.state."${keyB}"]`, + 'enabled = true', + 'trusted_hash = "sha256:BBB"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.size).toBe(2) + expect(result.get(keyA)?.trustedHash).toBe('sha256:AAA') + expect(result.get(keyA)?.enabled).toBe(true) + expect(result.get(keyB)?.trustedHash).toBe('sha256:BBB') + expect(result.get(keyB)?.enabled).toBe(true) + }) + + it('unescapes `\\\\` in the block key', () => { + // Why: a real Windows path on disk like C:\foo gets written escaped as + // `C:\\foo` inside the TOML key — the returned Map should expose the + // original unescaped form. + const original = [ + '[hooks.state."C:\\\\foo\\\\hooks.json:pre_tool_use:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:WIN"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.get('C:\\foo\\hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN') + }) + + it('reads entries from a CRLF-terminated config', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:CRLF"', + '' + ].join('\r\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.get(key)?.trustedHash).toBe('sha256:CRLF') + expect(result.get(key)?.enabled).toBe(true) + }) + + it('keeps blocks that have no `trusted_hash` field so callers can see enabled-only state', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [`[hooks.state."${key}"]`, 'enabled = false', ''].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.size).toBe(1) + expect(result.get(key)).toEqual({ trustedHash: undefined, enabled: false }) + }) + + it('reads disabled state alongside a valid trusted hash', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + `[hooks.state."${key}"]`, + 'enabled = false', + 'trusted_hash = "sha256:DISABLED"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.get(key)).toEqual({ trustedHash: 'sha256:DISABLED', enabled: false }) + }) + + it('does not extract a fake [hooks.state.""] header from inside a """ block', () => { + // Why: a header-shaped line embedded in a multi-line basic string must not + // be parsed as a real trust entry. + const original = [ + 'description = """', + '[hooks.state."fake-key"]', + 'enabled = true', + 'trusted_hash = "sha256:FAKE"', + '"""', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.size).toBe(0) + }) + + it("does not extract a fake [hooks.state.\"\"] header from inside a ''' block", () => { + // Why: same false-positive guard for multi-line literal strings. + const original = [ + "description = '''", + '[hooks.state."fake-key"]', + 'enabled = true', + 'trusted_hash = "sha256:FAKE"', + "'''", + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.size).toBe(0) + }) + + it('reads a block whose header has an inline comment', () => { + // Why: regression — headerLineRegex used to reject TOML-valid trailing + // comments, hiding existing trust entries from getStatus and causing + // it to misreport hooks as untrusted. + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:CMT"\n` + writeFileSync(configPath, original, 'utf-8') + + const result = readHookTrustEntries(configPath) + expect(result.size).toBe(1) + expect(result.get(key)?.trustedHash).toBe('sha256:CMT') + }) +}) + +describe('parseTrustKey', () => { + it('parses a typical posix-style key', () => { + expect(parseTrustKey('/Users/x/.codex/hooks.json:pre_tool_use:0:0')).toEqual({ + sourcePath: '/Users/x/.codex/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0 + }) + }) + + it('parses a Windows-style sourcePath whose drive letter contains a colon', () => { + // Why: validates the "anchor on the LAST three colons" approach so colons + // inside the sourcePath itself round-trip correctly. + expect(parseTrustKey('C:\\Users\\x\\.codex\\hooks.json:session_start:2:3')).toEqual({ + sourcePath: 'C:\\Users\\x\\.codex\\hooks.json', + eventLabel: 'session_start', + groupIndex: 2, + handlerIndex: 3 + }) + }) + + it('returns null for a non-Codex event label', () => { + expect(parseTrustKey('/x/hooks.json:not_an_event:0:0')).toBeNull() + }) + + it('returns null for a key with too few colons', () => { + expect(parseTrustKey('foo:bar')).toBeNull() + expect(parseTrustKey('foo')).toBeNull() + }) + + it('returns null when the group index is not an integer', () => { + expect(parseTrustKey('/x/hooks.json:pre_tool_use:abc:0')).toBeNull() + }) + + it('returns null when the handler index is not an integer', () => { + expect(parseTrustKey('/x/hooks.json:pre_tool_use:0:abc')).toBeNull() + }) + + it('returns null when the source path is empty', () => { + expect(parseTrustKey(':pre_tool_use:0:0')).toBeNull() + }) + + it('round-trips with computeTrustKey', () => { + const entry: CodexTrustEntry = { + sourcePath: '/Users/x/.codex/hooks.json', + eventLabel: 'post_tool_use', + groupIndex: 4, + handlerIndex: 7, + command: 'irrelevant' + } + const parsed = parseTrustKey(computeTrustKey(entry)) + expect(parsed).toEqual({ + sourcePath: entry.sourcePath, + eventLabel: entry.eventLabel, + groupIndex: entry.groupIndex, + handlerIndex: entry.handlerIndex + }) + }) + + // Why: Number('') === 0 silently passes Number.isInteger; without strict + // canonical-form validation, malformed keys would coerce into valid ones. + it('returns null for empty group/handler segments', () => { + expect(parseTrustKey('/x/hooks.json:pre_tool_use::0')).toBeNull() + expect(parseTrustKey('/x/hooks.json:pre_tool_use:0:')).toBeNull() + expect(parseTrustKey('/x/hooks.json:pre_tool_use::')).toBeNull() + }) + + it('returns null for exponent or whitespace numeric segments', () => { + expect(parseTrustKey('/x/hooks.json:pre_tool_use:1e2:0')).toBeNull() + expect(parseTrustKey('/x/hooks.json:pre_tool_use: 0:0')).toBeNull() + expect(parseTrustKey('/x/hooks.json:pre_tool_use:01:0')).toBeNull() + }) +}) + +describe('upsertHookTrustEntries with array-of-tables boundaries', () => { + // Why: findNextTableHeader must treat `[[array.of.tables]]` as a block + // boundary; otherwise an upsert/remove can consume past array entries + // into unrelated user content. + it('stops the replacement at a following [[array.of.tables]] header', () => { + const key = '/x/hooks.json:pre_tool_use:0:0' + const original = [ + `[hooks.state."${key}"]`, + 'enabled = true', + 'trusted_hash = "sha256:STALE"', + '', + '[[products]]', + 'name = "thing"', + '' + ].join('\n') + writeFileSync(configPath, original, 'utf-8') + + upsertHookTrustEntries(configPath, [ + { + sourcePath: '/x/hooks.json', + eventLabel: 'pre_tool_use', + groupIndex: 0, + handlerIndex: 0, + command: 'echo' + } + ]) + + const written = readFileSync(configPath, 'utf-8') + expect(written).not.toContain('STALE') + expect(written).toContain('[[products]]') + expect(written).toContain('name = "thing"') + }) +}) diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts new file mode 100644 index 00000000000..1bd8594e8c9 --- /dev/null +++ b/src/main/codex/config-toml-trust.ts @@ -0,0 +1,577 @@ +/* eslint-disable max-lines -- Why: Codex hook trust parsing, hashing, and byte-preserving TOML edits share one fragile file-format contract; splitting would make the compatibility shim harder to audit. */ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'fs' +import { dirname, join } from 'path' +import { createHash, randomUUID } from 'crypto' + +// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in +// ~/.codex/config.toml under [hooks.state.""]. Without it the hook is in +// the "review required" pile and never fires, so the agent-status sidebar +// silently goes blank. We reproduce Codex's hash so install() can register +// trust the same way `/hooks` would. Algorithm reverse-engineered from +// codex-rs/hooks/src/engine/discovery.rs (command_hook_hash) + +// codex-rs/config/src/fingerprint.rs (version_for_toml). + +export type CodexEventLabel = + | 'pre_tool_use' + | 'permission_request' + | 'post_tool_use' + | 'pre_compact' + | 'post_compact' + | 'session_start' + | 'user_prompt_submit' + | 'stop' + +export type CodexTrustEntry = { + /** Path on disk to the hooks.json that declares the hook (the "key_source"). */ + sourcePath: string + /** Codex event label (snake_case). */ + eventLabel: CodexEventLabel + /** 0-based index of the matcher group within the event array. */ + groupIndex: number + /** 0-based index of the handler within the matcher group's `hooks` array. */ + handlerIndex: number + /** The exact `command` string written to hooks.json. */ + command: string + /** Effective timeout in seconds. When undefined, defaults to 600. + * Explicit values are clamped to a minimum of 1. */ + timeoutSec?: number + /** Whether the handler is async. Defaults to false. */ + async?: boolean + /** Optional matcher pattern (only meaningful for events that support it). */ + matcher?: string + /** Optional statusMessage field. */ + statusMessage?: string +} + +export type CodexHookTrustState = { + trustedHash?: string + enabled?: boolean +} + +// Why: matches Codex's canonical_json. Sorts object keys recursively before +// SHA-256ing; arrays preserve order. +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize) + } + if (value && typeof value === 'object') { + const sorted: Record = {} + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = canonicalize((value as Record)[key]) + } + return sorted + } + return value +} + +// Why: reproduces command_hook_hash. NormalizedHookIdentity has `group: +// MatcherGroup` flattened in, so the wire shape is { event_name, matcher?, +// hooks: [] }. `matcher` is omitted (not null) when +// absent — Rust's Option=None drops through the TOML→JSON path. +// Handler is normalized to timeout=600 (or explicit, min 1) and async=false. +export function computeTrustedHash(entry: CodexTrustEntry): string { + const handler: Record = { + type: 'command', + command: entry.command, + timeout: Math.max(1, entry.timeoutSec ?? 600), + async: entry.async ?? false + } + if (entry.statusMessage !== undefined) { + handler.statusMessage = entry.statusMessage + } + const identity: Record = { + event_name: entry.eventLabel, + hooks: [handler] + } + if (entry.matcher !== undefined) { + identity.matcher = entry.matcher + } + const serialized = JSON.stringify(canonicalize(identity)) + return `sha256:${createHash('sha256').update(serialized).digest('hex')}` +} + +export function computeTrustKey(entry: CodexTrustEntry): string { + return `${entry.sourcePath}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` +} + +export function parseTrustKey(key: string): { + sourcePath: string + eventLabel: CodexEventLabel + groupIndex: number + handlerIndex: number +} | null { + // Why: keys have shape `:::`. + // sourcePath itself may contain `:` (Windows drive letters), so anchor the + // parse at the LAST three colons rather than the first. + const lastColon = key.lastIndexOf(':') + if (lastColon === -1) { + return null + } + const handlerStr = key.slice(lastColon + 1) + if (!isCanonicalNonNegativeInt(handlerStr)) { + return null + } + const secondLast = key.lastIndexOf(':', lastColon - 1) + if (secondLast === -1) { + return null + } + const groupStr = key.slice(secondLast + 1, lastColon) + if (!isCanonicalNonNegativeInt(groupStr)) { + return null + } + const thirdLast = key.lastIndexOf(':', secondLast - 1) + if (thirdLast === -1) { + return null + } + const eventLabel = key.slice(thirdLast + 1, secondLast) + if (!isCodexEventLabel(eventLabel)) { + return null + } + const sourcePath = key.slice(0, thirdLast) + if (sourcePath.length === 0) { + return null + } + return { + sourcePath, + eventLabel, + groupIndex: Number(groupStr), + handlerIndex: Number(handlerStr) + } +} + +// Why: Number('') === 0 and Number('1e2') === 100 both pass Number.isInteger, +// so reject any non-canonical decimal form before numeric conversion. +function isCanonicalNonNegativeInt(value: string): boolean { + return /^(0|[1-9]\d*)$/.test(value) +} + +function isCodexEventLabel(value: string): value is CodexEventLabel { + return ( + value === 'pre_tool_use' || + value === 'permission_request' || + value === 'post_tool_use' || + value === 'pre_compact' || + value === 'post_compact' || + value === 'session_start' || + value === 'user_prompt_submit' || + value === 'stop' + ) +} + +// Why: TOML 1.0 forbids BOMs but real-world editors (especially on Windows) sometimes +// write them. A leading  would break header regexes anchored at `^[ \t]*\[`, so +// strip it once at the file boundary and let the rest of the parser stay simple. +function readTomlFile(configPath: string): string { + const raw = readFileSync(configPath, 'utf-8') + return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw +} + +// Why: regex-edit ~/.codex/config.toml rather than parse + reserialize. The +// file is hand-edited by users (and other tools) and a round-trip through +// any TOML library would lose comments, key ordering, and inline-table +// style. We only ever (a) replace an existing [hooks.state.""] block +// keyed by *our* known hook keys, or (b) append a new block at EOF. Other +// content is byte-preserved. +// Why: this is a read-modify-write with no inter-process lock. Codex CLI's +// /hooks flow also writes [hooks.state.*] blocks in this file, so two +// concurrent writers can lose each other's edits. writeConfigAtomically +// prevents partial writes but not lost updates. install() is idempotent +// (deterministic hashes), so the next install() repairs drift. +export function upsertHookTrustEntries( + configPath: string, + entries: readonly CodexTrustEntry[] +): void { + const existing = existsSync(configPath) ? readTomlFile(configPath) : '' + let updated = existing + for (const entry of entries) { + updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry)) + } + if (updated === existing) { + return + } + writeConfigAtomically(configPath, updated) +} + +// Why: build the canonical block we own. The two field names mirror what +// Codex itself writes when the user approves via /hooks (HookStateToml +// fields). `enabled` is plumbed through so an existing user-set +// `enabled = false` survives reinstall. +function buildTrustBlock(key: string, hash: string, enabled: boolean): string { + return [ + `[hooks.state."${escapeTomlString(key)}"]`, + `enabled = ${enabled}`, + `trusted_hash = "${escapeTomlString(hash)}"` + ].join('\n') +} + +// Why: TOML basic strings forbid raw control chars; escape backslash first so +// later substitutions don't double-escape the inserted backslashes. +function escapeTomlString(value: string): string { + return value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\b', '\\b') + .replaceAll('\f', '\\f') + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('\t', '\\t') +} + +function upsertTrustBlock(content: string, key: string, hash: string): string { + const headerPattern = buildHeaderPattern(key) + const match = headerPattern.exec(content) + if (!match) { + const block = buildTrustBlock(key, hash, true) + if (content.length === 0) { + return `${block}\n` + } + // Why: leave one blank line before our appended block so the file stays + // readable, but don't compound separators when the file already ends in + // a blank line. + const separator = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n' + return `${content}${separator}${block}\n` + } + const headerStart = match.index + (match[1] ? match[1].length : 0) + const headerLineEnd = match.index + match[0].length + // Why: find the next top-level table header [...] so we replace ONLY this + // block. Comments and blank lines between us and the next header are part + // of our block and get rewritten — Codex itself only writes the two known + // fields, so this is safe. + const after = content.slice(headerLineEnd) + const nextHeaderRel = findNextTableHeader(after) + const blockEnd = nextHeaderRel === -1 ? content.length : headerLineEnd + nextHeaderRel + // Why: preserve a user-set `enabled = false` so a hand-disabled hook is not + // silently re-enabled by the next auto-install on app start. + const existingBlock = content.slice(headerLineEnd, blockEnd) + const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec( + existingBlock + ) + const enabled = enabledMatch ? enabledMatch[1] === 'true' : true + const block = buildTrustBlock(key, hash, enabled) + return `${content.slice(0, headerStart)}${block}\n${content.slice(blockEnd)}` +} + +// Why: Codex emits the canonical form with the key double-quoted; we never +// share this slot with another tool, so we don't bother accepting bare +// dotted-key variants. +// Why: accept both LF and CRLF — Windows editors (and some user-edited files) +// terminate the header line with \r\n. +// Why: TOML allows leading whitespace before headers, so accept indented +// headers — the reader does too, and a column-0-only writer would otherwise +// append a duplicate block on hand-indented configs. +// Why: trailing newline is a lookahead so a header at EOS without a final +// newline still matches (otherwise we append a duplicate block). +// Why: TOML allows `# inline comment` after `]`, so accept it before the +// line-end lookahead — otherwise a user-annotated header would force the +// no-match branch and append a duplicate block. +function buildHeaderPattern(key: string): RegExp { + const escapedKey = escapeRegex(escapeTomlString(key)) + return new RegExp( + `(^|\\r?\\n)[ \\t]*\\[hooks\\.state\\."${escapedKey}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)` + ) +} + +function escapeRegex(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines +// inside multi-line strings aren't headers, so we need a stateful scanner — +// a flat regex misclassifies both cases. +function findNextTableHeader(text: string): number { + let cursor = 0 + let inMultilineBasic = false + let inMultilineLiteral = false + while (cursor < text.length) { + const newlineIdx = text.indexOf('\n', cursor) + const lineEnd = newlineIdx === -1 ? text.length : newlineIdx + const rawLine = text.slice(cursor, lineEnd) + const line = rawLine.replace(/\r$/, '') + if (inMultilineBasic) { + if (countUnescapedTripleQuote(line, '"""') % 2 === 1) { + inMultilineBasic = false + } + } else if (inMultilineLiteral) { + if (countTripleQuote(line, "'''") % 2 === 1) { + inMultilineLiteral = false + } + } else { + const trimmed = line.trimStart() + // Why: stop at both `[table]` and `[[array.of.tables]]` — both end our + // block. Skipping `[[ ]]` here would let our slice consume past array + // entries into unrelated user content. + if (trimmed.startsWith('[') && isCompleteTableHeader(trimmed)) { + return cursor + } + // Odd count means this line opens a multi-line string without closing it. + if (countUnescapedTripleQuote(line, '"""') % 2 === 1) { + inMultilineBasic = true + } + if (countTripleQuote(line, "'''") % 2 === 1) { + inMultilineLiteral = true + } + } + if (newlineIdx === -1) { + return -1 + } + cursor = newlineIdx + 1 + } + return -1 +} + +// Why: literal strings (`'''`) don't honor escapes, so a plain indexOf scan +// suffices. +function countTripleQuote(line: string, quote: string): number { + let count = 0 + let i = 0 + while ((i = line.indexOf(quote, i)) !== -1) { + count++ + i += 3 + } + return count +} + +// Why: basic multi-line strings honor `\"` (and `\\`) escapes. Skip past +// `\` so an escaped quote doesn't count toward triple-quote scans. +function countUnescapedTripleQuote(line: string, quote: '"""'): number { + let count = 0 + let i = 0 + while (i < line.length) { + if (line[i] === '\\' && i + 1 < line.length) { + i += 2 + continue + } + if (line.startsWith(quote, i)) { + count++ + i += 3 + continue + } + i++ + } + return count +} + +// Why: walk the header byte-by-byte so `]` inside a quoted key segment +// doesn't terminate us early. Basic strings honor `\` escapes; literal +// strings (single quotes) don't allow escapes per TOML spec. +// Accepts both `[table]` and `[[array.of.tables]]` since either ends a block. +function isCompleteTableHeader(line: string): boolean { + if (!line.startsWith('[')) { + return false + } + const isArrayHeader = line.startsWith('[[') + let i = isArrayHeader ? 2 : 1 + let inBasicQuote = false + let inLiteralQuote = false + while (i < line.length) { + const ch = line[i] + if (inBasicQuote) { + if (ch === '\\' && i + 1 < line.length) { + i += 2 + continue + } + if (ch === '"') { + inBasicQuote = false + } + i++ + continue + } + if (inLiteralQuote) { + if (ch === "'") { + inLiteralQuote = false + } + i++ + continue + } + if (ch === '"') { + inBasicQuote = true + i++ + continue + } + if (ch === "'") { + inLiteralQuote = true + i++ + continue + } + if (ch === ']') { + if (isArrayHeader) { + if (line[i + 1] !== ']') { + return false + } + const tail = line.slice(i + 2) + return /^\s*(#.*)?$/.test(tail) + } + const tail = line.slice(i + 1) + return /^\s*(#.*)?$/.test(tail) + } + i++ + } + return false +} + +// Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a +// half-written config.toml can brick a user's Codex install, so write to +// tmp and rename. Random-suffix tmp name avoids cross-process races on +// rapid reinstalls. +function writeConfigAtomically(configPath: string, contents: string): void { + const dir = dirname(configPath) + mkdirSync(dir, { recursive: true }) + const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) + let renamed = false + try { + writeFileSync(tmpPath, contents, 'utf-8') + if (existsSync(configPath)) { + copyFileSync(configPath, `${configPath}.bak`) + } + renameSync(tmpPath, configPath) + renamed = true + } finally { + if (!renamed && existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // best effort — surfacing the cleanup failure would mask the original write error + } + } + } +} + +export function removeHookTrustEntries(configPath: string, keys: readonly string[]): void { + if (!existsSync(configPath)) { + return + } + const existing = readTomlFile(configPath) + let updated = existing + for (const key of keys) { + updated = removeTrustBlock(updated, key) + } + if (updated === existing) { + return + } + writeConfigAtomically(configPath, updated) +} + +function removeTrustBlock(content: string, key: string): string { + const headerPattern = buildHeaderPattern(key) + const match = headerPattern.exec(content) + if (!match) { + return content + } + // Why: skip past the captured leading newline so we don't fuse the previous + // line into the next header (e.g. `a = 1[other]` — invalid TOML). + const cutStart = match.index + (match[1] ? match[1].length : 0) + const headerLineEnd = match.index + match[0].length + const after = content.slice(headerLineEnd) + const nextHeaderRel = findNextTableHeader(after) + const cutEnd = nextHeaderRel === -1 ? content.length : headerLineEnd + nextHeaderRel + return content.slice(0, cutStart) + content.slice(cutEnd) +} + +export function readHookTrustEntries(configPath: string): Map { + const result = new Map() + if (!existsSync(configPath)) { + return result + } + const content = readTomlFile(configPath) + // Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or + // `'''...'''` multi-line string isn't mistaken for a real header. + // Why: accept an optional `# inline comment` after `]` — TOML permits it, + // and rejecting hides a real entry, making getStatus misreport trustMissing. + const headerLineRegex = /^[ \t]*\[hooks\.state\."((?:[^"\\]|\\.)*)"\][ \t]*(?:#[^\r\n]*)?$/ + let cursor = 0 + let inMultilineBasic = false + let inMultilineLiteral = false + while (cursor < content.length) { + const newlineIdx = content.indexOf('\n', cursor) + const lineEnd = newlineIdx === -1 ? content.length : newlineIdx + const rawLine = content.slice(cursor, lineEnd) + const line = rawLine.replace(/\r$/, '') + const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 + if (inMultilineBasic) { + if (countUnescapedTripleQuote(line, '"""') % 2 === 1) { + inMultilineBasic = false + } + cursor = nextCursor + continue + } + if (inMultilineLiteral) { + if (countTripleQuote(line, "'''") % 2 === 1) { + inMultilineLiteral = false + } + cursor = nextCursor + continue + } + const headerMatch = headerLineRegex.exec(line) + if (headerMatch) { + const escapedKey = headerMatch[1] + const key = unescapeTomlString(escapedKey) + // Why: block ends at the next *real* header (multi-line aware). + const after = content.slice(nextCursor) + const nextHeaderRel = findNextTableHeader(after) + const blockEnd = nextHeaderRel === -1 ? content.length : nextCursor + nextHeaderRel + const block = content.slice(nextCursor, blockEnd) + // Why: we own this block's shape (only `enabled` + `trusted_hash`), so + // a line scan beats pulling in a full TOML value parser. + const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"/m.exec(block) + const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block) + result.set(key, { + trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : undefined, + enabled: enabledMatch ? enabledMatch[1] === 'true' : undefined + }) + cursor = nextCursor + continue + } + if (countUnescapedTripleQuote(line, '"""') % 2 === 1) { + inMultilineBasic = true + } + if (countTripleQuote(line, "'''") % 2 === 1) { + inMultilineLiteral = true + } + cursor = nextCursor + } + return result +} + +function unescapeTomlString(escaped: string): string { + let result = '' + let i = 0 + while (i < escaped.length) { + const ch = escaped[i] + if (ch === '\\' && i + 1 < escaped.length) { + const next = escaped[i + 1] + if (next === 'n') { + result += '\n' + } else if (next === 'r') { + result += '\r' + } else if (next === 't') { + result += '\t' + } else if (next === 'b') { + result += '\b' + } else if (next === 'f') { + result += '\f' + } else if (next === '"') { + result += '"' + } else if (next === '\\') { + result += '\\' + } + // Why: unknown escapes round-trip — preserve the backslash so we don't + // silently drop information. + else { + result += `\\${next}` + } + i += 2 + } else { + result += ch + i++ + } + } + return result +} diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index d57555c06d5..35627d36420 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */ import { homedir } from 'os' import { join } from 'path' import { app } from 'electron' @@ -6,10 +7,22 @@ import { createManagedCommandMatcher, readHooksJson, removeManagedCommands, + wrapPosixHookCommand, writeHooksJson, writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { + computeTrustKey, + computeTrustedHash, + parseTrustKey, + readHookTrustEntries, + removeHookTrustEntries, + upsertHookTrustEntries, + type CodexEventLabel, + type CodexHookTrustState, + type CodexTrustEntry +} from './config-toml-trust' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -30,6 +43,22 @@ function getConfigPath(): string { return join(homedir(), '.codex', 'hooks.json') } +function getCodexConfigTomlPath(): string { + return join(homedir(), '.codex', 'config.toml') +} + +// Why: Codex's hash key uses the snake_case event label (see +// codex-rs/hooks/src/lib.rs::hook_event_key_label). Our hooks.json uses the +// PascalCase serde-rename. Map between them at one place so the trust-write +// path can't drift from the install path. +const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> = { + SessionStart: 'session_start', + UserPromptSubmit: 'user_prompt_submit', + PreToolUse: 'pre_tool_use', + PostToolUse: 'post_tool_use', + Stop: 'stop' +} + function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' } @@ -39,7 +68,7 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"` + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } function getManagedScript(): string { @@ -108,37 +137,102 @@ export class CodexHookService { } } - // Why: Report `partial` when only some managed events are registered so the - // sidebar surfaces a degraded install rather than a false-positive - // `installed`. Each CODEX_EVENTS entry must contain the managed command for - // the integration to function end-to-end (e.g. PreToolUse is required for - // permission-prompt detection per the comment above). + // Why: Report `partial` when managed events are missing OR when their + // trust entries are missing/stale. Codex 0.129+ silently drops untrusted + // hooks, so a green status without trust verification is misleading. const command = getManagedCommand(scriptPath) + const tomlPath = getCodexConfigTomlPath() + // Why: an unreadable config.toml (EACCES/EIO) is distinct from "file + // absent" (which returns an empty Map without throwing). Hooks.json may + // still be fine, so report partial with a specific reason rather than + // collapsing to a generic error or masking it as universally-stale trust. + let trustEntries: Map + let trustReadError: string | null = null + try { + trustEntries = readHookTrustEntries(tomlPath) + } catch (error) { + trustEntries = new Map() + trustReadError = error instanceof Error ? error.message : String(error) + } + const missing: string[] = [] + const trustMissing: string[] = [] + const disabled: string[] = [] let presentCount = 0 for (const eventName of CODEX_EVENTS) { const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] - const hasCommand = definitions.some((definition) => - (definition.hooks ?? []).some((hook) => hook.command === command) - ) - if (hasCommand) { - presentCount += 1 - } else { + // Why: install() appends our managed definition at the end, so its + // group index is the LAST match. Picking the first match would + // misreport stale duplicates as trust-missing. + let foundGroupIndex = -1 + let foundHandlerIndex = -1 + definitions.forEach((definition, idx) => { + const hooks = definition.hooks ?? [] + // Why: mirror the LAST-match-wins rule at the group level — if a user + // merged hook arrays and ended up with our command at multiple indices + // in one group, the surviving runtime entry is the last one. + const handlerIdx = hooks.findLastIndex((hook) => hook.command === command) + if (handlerIdx !== -1) { + foundGroupIndex = idx + foundHandlerIndex = handlerIdx + } + }) + if (foundGroupIndex === -1) { missing.push(eventName) + continue + } + presentCount += 1 + // Why: a stale hash blocks firing the same as a missing entry, so + // compare against the canonical hash we would write. + // Why: capture the actual handler index — Codex's hook_key uses the + // positional handlerIndex, and a user-merged hook array can put our + // command at a non-zero slot, so hardcoding 0 would misreport trust. + const trustInput: CodexTrustEntry = { + sourcePath: configPath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: foundGroupIndex, + handlerIndex: foundHandlerIndex, + command + } + const expectedHash = computeTrustedHash(trustInput) + const actualState = trustEntries.get(computeTrustKey(trustInput)) + if (actualState?.trustedHash !== expectedHash) { + trustMissing.push(eventName) + } else if (actualState?.enabled === false) { + disabled.push(eventName) } } const managedHooksPresent = presentCount > 0 let state: AgentHookInstallState let detail: string | null - if (missing.length === 0) { - state = 'installed' - detail = null - } else if (presentCount === 0) { + if (presentCount === 0) { state = 'not_installed' + // Why: surface the trust read error even when not_installed so the user + // has actionable info if config.toml is broken. + detail = trustReadError !== null ? `Trust entries unverifiable: ${trustReadError}` : null + } else if ( + missing.length === 0 && + trustMissing.length === 0 && + disabled.length === 0 && + trustReadError === null + ) { + state = 'installed' detail = null } else { state = 'partial' - detail = `Managed hook missing for events: ${missing.join(', ')}` + const parts: string[] = [] + if (missing.length > 0) { + parts.push(`Managed hook missing for events: ${missing.join(', ')}`) + } + if (trustReadError !== null) { + parts.push(`Trust entries unverifiable: ${trustReadError}`) + } else if (trustMissing.length > 0) { + parts.push(`Trust entry missing or stale for events: ${trustMissing.join(', ')}`) + } + if (disabled.length > 0) { + parts.push(`Managed hook disabled for events: ${disabled.join(', ')}`) + } + detail = parts.join('; ') } return { agent: 'codex', state, configPath, managedHooksPresent, detail } } @@ -190,6 +284,11 @@ export class CodexHookService { } } + // Why: Codex 0.129+ requires a per-hook trust entry in config.toml or the + // hook sits in the "review required" pile. We compute the trust hash for + // each managed entry as we install it and persist it alongside hooks.json + // so the user does not have to /hooks-approve after every install. + const trustEntries: CodexTrustEntry[] = [] for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -197,11 +296,36 @@ export class CodexHookService { hooks: [{ type: 'command', command }] } nextHooks[eventName] = [...cleaned, definition] + // Why: our managed definition is appended after `cleaned`, so its + // group index in the resulting hooks.json is `cleaned.length`. The + // handler is always the first (and only) entry in the group, so + // handler index is 0. Codex's hook_key uses these positional indices. + trustEntries.push({ + sourcePath: configPath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: cleaned.length, + handlerIndex: 0, + command + }) } config.hooks = nextHooks writeManagedScript(scriptPath, getManagedScript()) writeHooksJson(configPath, config) + // Why: trust entries write last so a half-write can't leave a hash + // pointing at a hook that doesn't exist. Surface failures — without this, + // getStatus would report green for a hook Codex won't actually fire. + try { + upsertHookTrustEntries(getCodexConfigTomlPath(), trustEntries) + } catch (error) { + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: true, + detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + } + } return this.getStatus() } @@ -238,6 +362,56 @@ export class CodexHookService { } config.hooks = nextHooks writeHooksJson(configPath, config) + + // Why: also drop our trust entries so config.toml doesn't accumulate dead + // [hooks.state."..."] blocks across install/remove cycles. Best-effort — + // a stale entry is harmless once hooks.json no longer references it. + try { + const tomlPath = getCodexConfigTomlPath() + const existingEntries = readHookTrustEntries(tomlPath) + const scriptPath = getManagedScriptPath() + const command = getManagedCommand(scriptPath) + const managedEventLabels = new Set( + CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) + ) + // Why: only drop entries WE wrote. configPath (~/.codex/hooks.json) is + // shared with Codex CLI, so user-approved trust entries for non-Orca + // commands live in the same `[hooks.state.*]` namespace. Match by hash + // equivalence to our managed command — a sourcePath-only filter would + // wipe the user's manually-approved entries. + const ourKeys: string[] = [] + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if (parts === null) { + continue + } + if (parts.sourcePath !== configPath) { + continue + } + if (!managedEventLabels.has(parts.eventLabel)) { + continue + } + const expectedHash = computeTrustedHash({ + sourcePath: configPath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command + }) + if (state.trustedHash !== expectedHash) { + continue + } + ourKeys.push(key) + } + if (ourKeys.length > 0) { + removeHookTrustEntries(tomlPath, ourKeys) + } + } catch (error) { + // Best effort — stale trust entries are harmless once hooks.json no + // longer references the hook. Log so a programmer error doesn't disappear silently. + console.warn('[codex-hook-service] failed to clean trust entries', error) + } + return this.getStatus() } } diff --git a/src/main/computer/desktop-script-provider-client.test.ts b/src/main/computer/desktop-script-provider-client.test.ts new file mode 100644 index 00000000000..cdc5436dc7e --- /dev/null +++ b/src/main/computer/desktop-script-provider-client.test.ts @@ -0,0 +1,453 @@ +/* eslint-disable max-lines -- Why: desktop provider contract coverage shares one mocked bridge harness. */ +import { execFile } from 'child_process' +import { readFileSync } from 'fs' +import { describe, expect, it, vi } from 'vitest' +import { DesktopScriptProviderClient } from './desktop-script-provider-client' + +vi.mock('child_process', () => ({ + execFile: vi.fn() +})) + +describe('DesktopScriptProviderClient', () => { + it('normalizes list-apps responses', async () => { + mockBridgeResponse({ + ok: true, + apps: [{ name: 'Notepad', bundleIdentifier: 'notepad', pid: 42 }] + }) + + const client = new DesktopScriptProviderClient('windows', '/tmp/runtime.ps1') + + await expect(client.listApps()).resolves.toEqual({ + apps: [ + { + name: 'Notepad', + bundleId: 'notepad', + pid: 42, + isRunning: true, + lastUsedAt: null, + useCount: null + } + ] + }) + }) + + it('normalizes snapshots and remembers elements for follow-up actions', async () => { + mockBridgeResponse({ + ok: true, + snapshot: sampleBridgeSnapshot('Text Editor', 'initial') + }) + mockBridgeResponse({ + ok: true, + snapshot: sampleBridgeSnapshot('Text Editor', 'changed') + }) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + const initial = await client.snapshot({ app: 'Text Editor' }) + expect(initial.snapshot.elementCount).toBe(1) + expect(initial.snapshot.window).toMatchObject({ + title: 'Text Editor', + id: 99, + x: 10, + y: 20, + width: 300, + height: 200 + }) + expect(initial.screenshotStatus).toEqual({ + state: 'captured', + metadata: { engine: 'unknown', windowId: 99 } + }) + expect(initial.snapshot.treeText).toContain('initial') + expect(publicSnapshotKeys(initial.snapshot)).toEqual([ + 'app', + 'coordinateSpace', + 'elementCount', + 'focusedElementId', + 'id', + 'treeText', + 'truncation', + 'window' + ]) + + await client.action('setValue', { + app: 'Text Editor', + elementIndex: 0, + value: 'changed', + noScreenshot: true + }) + + const secondCall = vi.mocked(execFile).mock.calls[1] + const operationPath = secondCall[1]?.at(-1) + expect(typeof operationPath).toBe('string') + }) + + it('targets cached elements by session and explicit window id', async () => { + mockBridgeResponse({ + ok: true, + snapshot: sampleBridgeSnapshot('Text Editor', 'initial') + }) + mockBridgeResponse( + { + ok: true, + snapshot: sampleBridgeSnapshot('Text Editor', 'changed') + }, + (operation) => { + expect(operation).toMatchObject({ + tool: 'drag', + app: 'Text Editor', + windowId: 99, + windowIndex: 0, + fromElement: expect.objectContaining({ index: 0 }), + toElement: expect.objectContaining({ index: 0 }) + }) + expect(operation.from_x).toBeUndefined() + expect(operation.to_x).toBeUndefined() + expect(operation.windowBounds).toBeNull() + } + ) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await client.snapshot({ app: 'Text Editor', session: 'agent-a', windowId: 99 }) + await client.action('drag', { + app: 'Text Editor', + session: 'agent-a', + windowId: 99, + windowIndex: 0, + fromElementIndex: 0, + toElementIndex: 0 + }) + }) + + it('forwards restore-window to desktop providers', async () => { + mockBridgeResponse( + { + ok: true, + snapshot: sampleBridgeSnapshot('Text Editor', 'initial') + }, + (operation) => { + expect(operation).toMatchObject({ + tool: 'get_app_state', + restoreWindow: true + }) + } + ) + mockBridgeResponse( + { + ok: true, + snapshot: sampleBridgeSnapshot('Text Editor', 'changed') + }, + (operation) => { + expect(operation).toMatchObject({ + tool: 'set_value', + restoreWindow: true + }) + } + ) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await client.snapshot({ app: 'Text Editor', restoreWindow: true }) + const result = await client.action('setValue', { + app: 'Text Editor', + elementIndex: 0, + value: 'changed', + restoreWindow: true, + noScreenshot: true + }) + + expect(result.action).toMatchObject({ + path: 'accessibility', + actionName: 'setValue' + }) + }) + + it('keeps window-index snapshots scoped to the matching session', async () => { + mockBridgeResponse({ + ok: true, + snapshot: sampleBridgeSnapshot('Text Editor', 'initial') + }) + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await client.snapshot({ app: 'Text Editor', session: 'agent-a', windowIndex: 0 }) + + await expect( + client.action('click', { + app: 'Text Editor', + session: 'agent-b', + windowIndex: 0, + elementIndex: 0 + }) + ).rejects.toMatchObject({ code: 'element_not_found' }) + }) + + it('explains screenshot capture failures while keeping accessibility state usable', async () => { + mockBridgeResponse({ + ok: true, + snapshot: { + ...sampleBridgeSnapshot('Text Editor', 'initial'), + screenshotPngBase64: null + } + }) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await expect(client.snapshot({ app: 'Text Editor' })).resolves.toMatchObject({ + snapshot: { + elementCount: 1 + }, + screenshotStatus: { + state: 'failed', + code: 'screenshot_failed', + message: expect.stringContaining('--no-screenshot') + } + }) + }) + + it('normalizes list-windows responses after provider handshake', async () => { + mockBridgeResponse({ + ok: true, + capabilities: { + platform: 'linux', + provider: 'orca-computer-use-linux', + providerVersion: '1.0.0', + protocolVersion: 1, + supports: { + apps: { list: true, bundleIds: false, pids: true }, + windows: { + list: true, + targetById: true, + targetByIndex: true, + focus: false, + moveResize: false + }, + observation: { + screenshot: true, + annotatedScreenshot: false, + elementFrames: true, + ocr: false + }, + actions: { + click: true, + typeText: true, + pressKey: true, + hotkey: false, + pasteText: false, + scroll: true, + drag: true, + setValue: true, + performAction: true + }, + surfaces: { menus: false, dialogs: false, dock: false, menubar: false } + } + } + }) + mockBridgeResponse({ + ok: true, + app: { name: 'Text Editor', bundleIdentifier: 'Text Editor', pid: 100 }, + windows: [ + { + index: 0, + app: { name: 'Text Editor', bundleIdentifier: 'Text Editor', pid: 100 }, + id: 99, + title: 'Document', + x: 10, + y: 20, + width: 300, + height: 200, + isMinimized: false, + isOffscreen: false, + screenIndex: null, + platform: { backend: 'at-spi' } + } + ] + }) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await expect(client.listWindows({ app: 'Text Editor' })).resolves.toMatchObject({ + app: { name: 'Text Editor', bundleId: 'Text Editor', pid: 100 }, + windows: [{ index: 0, id: 99, title: 'Document' }] + }) + }) + + it('maps bridge app errors to RuntimeClientError codes', async () => { + mockBridgeResponse({ ok: false, error: 'appNotFound("Missing")' }) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await expect(client.snapshot({ app: 'Missing' })).rejects.toMatchObject({ + code: 'app_not_found' + }) + }) + + it('maps blocked app bridge errors to a policy error', async () => { + mockBridgeResponse({ ok: false, error: 'appBlocked("1Password")' }) + + const client = new DesktopScriptProviderClient('windows', '/tmp/runtime.ps1') + + await expect(client.snapshot({ app: '1Password' })).rejects.toMatchObject({ + code: 'app_blocked' + }) + }) + + it('maps action-specific bridge errors to actionable codes', async () => { + mockBridgeResponse({ ok: false, error: 'element value is not settable' }) + mockBridgeResponse({ ok: false, error: 'Raise is not a valid secondary action' }) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await expect(client.snapshot({ app: 'Text Editor' })).rejects.toMatchObject({ + code: 'value_not_settable' + }) + await expect(client.snapshot({ app: 'Text Editor' })).rejects.toMatchObject({ + code: 'action_not_supported' + }) + }) + + it('rejects actions that the provider does not advertise', async () => { + mockBridgeResponse({ + ok: true, + capabilities: sampleCapabilities({ + hotkey: false, + pasteText: false + }) + }) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await expect( + client.action('pasteText', { app: 'Text Editor', text: 'hello' }) + ).rejects.toMatchObject({ + code: 'unsupported_capability', + message: expect.stringContaining('actions.pasteText') + }) + }) + + it('uses provider action metadata when bridge reports the actual path', async () => { + mockBridgeResponse({ + ok: true, + snapshot: sampleBridgeSnapshot('Text Editor', 'initial') + }) + mockBridgeResponse({ + ok: true, + action: { + path: 'accessibility', + actionName: 'Press', + fallbackReason: null + }, + snapshot: sampleBridgeSnapshot('Text Editor', 'changed') + }) + + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await client.snapshot({ app: 'Text Editor' }) + await expect( + client.action('click', { + app: 'Text Editor', + elementIndex: 0, + noScreenshot: true + }) + ).resolves.toMatchObject({ + action: { + path: 'accessibility', + actionName: 'Press' + } + }) + }) + + it('explains that missing element indexes require a fresh snapshot', async () => { + const client = new DesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await expect( + client.action('click', { app: 'Text Editor', elementIndex: 4 }) + ).rejects.toMatchObject({ + code: 'element_not_found', + message: expect.stringContaining('run get-app-state again') + }) + }) +}) + +function mockBridgeResponse( + response: unknown, + inspectOperation?: (operation: Record) => void +): void { + vi.mocked(execFile).mockImplementationOnce((_command, _args, _options, callback) => { + const operationPath = _args?.at(-1) + if (inspectOperation && typeof operationPath === 'string') { + inspectOperation(JSON.parse(readFileSync(operationPath, 'utf8')) as Record) + } + const done = callback as (error: Error | null, stdout: string, stderr: string) => void + done(null, JSON.stringify(response), '') + return null as never + }) +} + +function sampleBridgeSnapshot(name: string, value: string) { + return { + app: { name, bundleIdentifier: name, pid: 100 }, + snapshotId: 'snap-test', + windowTitle: name, + windowId: 99, + windowBounds: { x: 10, y: 20, width: 300, height: 200 }, + screenshotPngBase64: 'iVBORw0KGgo=', + coordinateSpace: 'window', + truncation: { truncated: false, maxNodes: 1200, maxDepth: 64, maxDepthReached: false }, + treeLines: [`0 text entry area, Value: ${value}`], + focusedSummary: 'text entry area', + elements: [ + { + index: 0, + runtimeId: [0, 0], + name: 'Body', + controlType: 'text', + localizedControlType: 'text entry area', + value, + frame: { x: 1, y: 2, width: 100, height: 20 }, + actions: ['SetValue'] + } + ] + } +} + +function sampleCapabilities(actions: Partial> = {}) { + return { + platform: 'linux', + provider: 'orca-computer-use-linux', + providerVersion: '1.0.0', + protocolVersion: 1, + supports: { + apps: { list: true, bundleIds: false, pids: true }, + windows: { + list: true, + targetById: true, + targetByIndex: true, + focus: false, + moveResize: false + }, + observation: { + screenshot: true, + annotatedScreenshot: false, + elementFrames: true, + ocr: false + }, + actions: { + click: true, + typeText: true, + pressKey: true, + hotkey: true, + pasteText: true, + scroll: true, + drag: true, + setValue: true, + performAction: true, + ...actions + }, + surfaces: { menus: false, dialogs: false, dock: false, menubar: false } + } + } +} + +function publicSnapshotKeys(snapshot: unknown): string[] { + return Object.keys(snapshot as Record).sort() +} diff --git a/src/main/computer/desktop-script-provider-client.ts b/src/main/computer/desktop-script-provider-client.ts new file mode 100644 index 00000000000..7d0c7a4a8a0 --- /dev/null +++ b/src/main/computer/desktop-script-provider-client.ts @@ -0,0 +1,742 @@ +/* eslint-disable max-lines -- Why: bridge schema, request mapping, and result normalization stay together so platform scripts have one audited contract. */ +import { execFile } from 'child_process' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import type { + ComputerActionMetadata, + ComputerActionResult, + ComputerListAppsResult, + ComputerListWindowsResult, + ComputerProviderCapabilities, + ComputerSnapshotResult +} from '../../shared/runtime-types' +import { + desktopScriptPlatform, + resolveDesktopScriptProviderPath, + type DesktopScriptPlatform +} from './desktop-script-provider-paths' +import { RuntimeClientError } from './runtime-client-error' + +type NativeMethod = + | 'handshake' + | 'listApps' + | 'listWindows' + | 'getAppState' + | 'click' + | 'performSecondaryAction' + | 'scroll' + | 'drag' + | 'typeText' + | 'pressKey' + | 'hotkey' + | 'pasteText' + | 'setValue' + +type NativeActionMethod = Exclude< + NativeMethod, + 'handshake' | 'listApps' | 'listWindows' | 'getAppState' +> + +type BridgeFrame = { + x: number + y: number + width: number + height: number +} + +type BridgeElement = { + index: number + runtimeId?: unknown + automationId?: string + name?: string + controlType?: string + localizedControlType?: string + className?: string + value?: string + nativeWindowHandle?: number + frame?: BridgeFrame | null + actions?: string[] +} + +type BridgeSnapshot = { + snapshotId?: string + app: { + name: string + bundleIdentifier?: string + bundleId?: string + pid: number + } + windowTitle?: string + windowId?: number | null + windowBounds?: BridgeFrame | null + screenshotPngBase64?: string | null + coordinateSpace?: 'window' + truncation?: { + truncated?: boolean + maxNodes?: number + maxDepth?: number + maxDepthReached?: boolean + } + treeLines?: string[] + focusedSummary?: string | null + focusedElementId?: number | null + selectedText?: string | null + elements?: BridgeElement[] +} + +type BridgeWindow = { + index: number + app: { + name: string + bundleIdentifier?: string | null + bundleId?: string | null + pid: number + } + id?: number | null + title: string + x?: number | null + y?: number | null + width: number + height: number + isMinimized?: boolean | null + isOffscreen?: boolean | null + screenIndex?: number | null + platform?: Record +} + +type BridgeResponse = { + ok: boolean + error?: string + capabilities?: ComputerProviderCapabilities + apps?: { + name: string + bundleIdentifier?: string | null + bundleId?: string | null + pid: number + }[] + app?: { + name: string + bundleIdentifier?: string | null + bundleId?: string | null + pid: number + } + windows?: BridgeWindow[] + snapshot?: BridgeSnapshot + action?: ComputerActionMetadata +} + +type BridgeRequest = { + tool: string + app?: string + element?: BridgeElement + fromElement?: BridgeElement + toElement?: BridgeElement + x?: number + y?: number + from_x?: number + from_y?: number + to_x?: number + to_y?: number + click_count?: number + mouse_button?: string + action?: string + direction?: string + pages?: number + text?: string + key?: string + value?: string + windowBounds?: BridgeFrame | null + windowId?: number + windowIndex?: number + noScreenshot?: boolean + restoreWindow?: boolean +} + +const REQUEST_TIMEOUT_MS = 30_000 + +export function shouldUseDesktopScriptProvider(): boolean { + return desktopScriptPlatform() !== null && resolveDesktopScriptProviderPath() !== null +} + +export class DesktopScriptProviderClient { + private readonly snapshots = new Map() + private providerCapabilities: ComputerProviderCapabilities | null = null + + constructor( + private readonly platform: DesktopScriptPlatform = requiredPlatform(), + private readonly scriptPath: string = requiredScriptPath() + ) {} + + async listApps(): Promise { + const response = await this.callBridge({ tool: 'list_apps' }) + return { + apps: (response.apps ?? []).map((app) => ({ + name: app.name, + bundleId: app.bundleId ?? app.bundleIdentifier ?? null, + pid: app.pid, + isRunning: true, + lastUsedAt: null, + useCount: null + })) + } + } + + async capabilities(): Promise { + return await this.readCapabilities() + } + + async listWindows(params: Record): Promise { + const capabilities = await this.readCapabilities() + if (!capabilities.supports.windows.list) { + throw new RuntimeClientError( + 'unsupported_capability', + `${capabilities.provider} does not support windows.list` + ) + } + const response = await this.callBridge({ + tool: 'list_windows', + app: stringParam(params, 'app') + }) + return { + app: normalizeBridgeApp(response.app), + windows: (response.windows ?? []).map((window) => ({ + index: window.index, + app: normalizeBridgeApp(window.app), + id: window.id ?? null, + title: window.title, + x: window.x ?? null, + y: window.y ?? null, + width: window.width, + height: window.height, + isMinimized: window.isMinimized ?? null, + isOffscreen: window.isOffscreen ?? null, + screenIndex: window.screenIndex ?? null, + platform: window.platform + })) + } + } + + async snapshot(params: Record): Promise { + const app = stringParam(params, 'app') + const response = await this.callBridge({ + tool: 'get_app_state', + app, + windowId: optionalNumberParam(params, 'windowId'), + windowIndex: optionalNumberParam(params, 'windowIndex'), + noScreenshot: params.noScreenshot === true, + restoreWindow: params.restoreWindow === true + }) + return this.rememberAndRender(app, response, params.noScreenshot === true, params) + } + + async action( + method: NativeActionMethod, + params: Record + ): Promise { + const app = stringParam(params, 'app') + await this.ensureActionSupported(method) + const explicitWindowId = optionalNumberParam(params, 'windowId') + const explicitWindowIndex = optionalNumberParam(params, 'windowIndex') + const current = this.currentSnapshot(app, explicitWindowId, params) + const element = elementParam(current, optionalNumberParam(params, 'elementIndex')) + const fromElement = elementParam(current, optionalNumberParam(params, 'fromElementIndex')) + const toElement = elementParam(current, optionalNumberParam(params, 'toElementIndex')) + const response = await this.callBridge({ + tool: bridgeTool(method), + app, + element, + fromElement, + toElement, + x: optionalNumberParam(params, 'x'), + y: optionalNumberParam(params, 'y'), + from_x: optionalNumberParam(params, 'fromX'), + from_y: optionalNumberParam(params, 'fromY'), + to_x: optionalNumberParam(params, 'toX'), + to_y: optionalNumberParam(params, 'toY'), + click_count: optionalNumberParam(params, 'clickCount'), + mouse_button: optionalStringParam(params, 'mouseButton'), + action: optionalStringParam(params, 'action'), + direction: optionalStringParam(params, 'direction'), + pages: optionalNumberParam(params, 'pages'), + text: optionalStringParam(params, 'text'), + key: optionalStringParam(params, 'key'), + value: optionalStringParam(params, 'value'), + windowBounds: null, + windowId: explicitWindowId ?? current?.windowId ?? undefined, + windowIndex: explicitWindowIndex, + noScreenshot: params.noScreenshot === true, + restoreWindow: params.restoreWindow === true + }) + return { + ...this.rememberAndRender(app, response, params.noScreenshot === true, params), + action: + response.action ?? + desktopActionMetadata(method, response.snapshot?.windowId ?? current?.windowId ?? null) + } + } + + private async ensureActionSupported(method: NativeActionMethod): Promise { + if (method !== 'hotkey' && method !== 'pasteText') { + return + } + const capabilities = await this.readCapabilities() + const actionKey = actionCapabilityKey(method) + if (!capabilities.supports.actions[actionKey]) { + throw new RuntimeClientError( + 'unsupported_capability', + `${capabilities.provider} does not support actions.${actionKey}` + ) + } + } + + private async callBridge(request: BridgeRequest): Promise { + const operationDirectory = await mkdtemp(join(tmpdir(), 'orca-computer-use-')) + const operationPath = join(operationDirectory, 'operation.json') + try { + await writeFile(operationPath, JSON.stringify(request), { encoding: 'utf8', mode: 0o600 }) + const { stdout, stderr } = await execBridge(this.platform, this.scriptPath, operationPath) + let response: BridgeResponse + try { + response = JSON.parse(stdout) as BridgeResponse + } catch (error) { + throw new RuntimeClientError( + 'accessibility_error', + `desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!response.ok) { + throw mapBridgeError(response.error ?? stderr) + } + return response + } finally { + await rm(operationDirectory, { force: true, recursive: true }) + } + } + + private async readCapabilities(): Promise { + if (this.providerCapabilities) { + return this.providerCapabilities + } + const response = await this.callBridge({ tool: 'handshake' }) + if (!response.capabilities) { + throw new RuntimeClientError( + 'accessibility_error', + 'desktop provider returned no capabilities' + ) + } + this.providerCapabilities = response.capabilities + return response.capabilities + } + + private rememberAndRender( + app: string, + response: BridgeResponse, + noScreenshot: boolean, + params: Record + ): ComputerSnapshotResult { + if (!response.snapshot) { + throw new RuntimeClientError('accessibility_error', 'desktop provider returned no snapshot') + } + this.rememberSnapshot(app, response.snapshot, params) + return renderSnapshot(response.snapshot, noScreenshot) + } + + private rememberSnapshot( + query: string, + snapshot: BridgeSnapshot, + params: Record + ): void { + const namespace = snapshotNamespace(params) + for (const key of [ + query, + snapshot.app.name, + snapshot.app.bundleId, + snapshot.app.bundleIdentifier, + String(snapshot.app.pid), + ...snapshotKeysForWindow(query, snapshot), + ...snapshotKeysForWindow(snapshot.app.name, snapshot), + ...(snapshot.app.bundleId ? snapshotKeysForWindow(snapshot.app.bundleId, snapshot) : []), + ...(snapshot.app.bundleIdentifier + ? snapshotKeysForWindow(snapshot.app.bundleIdentifier, snapshot) + : []), + ...snapshotKeysForWindowIndex(query, params), + ...snapshotKeysForWindowIndex(snapshot.app.name, params), + ...(snapshot.app.bundleId ? snapshotKeysForWindowIndex(snapshot.app.bundleId, params) : []), + ...(snapshot.app.bundleIdentifier + ? snapshotKeysForWindowIndex(snapshot.app.bundleIdentifier, params) + : []) + ]) { + if (key) { + if (!isExplicitSnapshotNamespace(namespace)) { + this.snapshots.set(key.toLowerCase(), snapshot) + } + this.snapshots.set(namespacedSnapshotKey(namespace, key), snapshot) + } + } + } + + private currentSnapshot( + app: string, + windowId: number | undefined, + params: Record + ): BridgeSnapshot | null { + const namespace = snapshotNamespace(params) + if (windowId !== undefined) { + const windowKey = snapshotWindowKey(app, windowId) + return ( + this.snapshots.get(namespacedSnapshotKey(namespace, windowKey)) ?? + (isExplicitSnapshotNamespace(namespace) ? undefined : this.snapshots.get(windowKey)) ?? + null + ) + } + const windowIndex = optionalNumberParam(params, 'windowIndex') + if (windowIndex !== undefined) { + const windowIndexKey = snapshotWindowIndexKey(app, windowIndex) + return ( + this.snapshots.get(namespacedSnapshotKey(namespace, windowIndexKey)) ?? + (isExplicitSnapshotNamespace(namespace) ? undefined : this.snapshots.get(windowIndexKey)) ?? + null + ) + } + return ( + this.snapshots.get(namespacedSnapshotKey(namespace, app)) ?? + (isExplicitSnapshotNamespace(namespace) + ? undefined + : this.snapshots.get(app.toLowerCase())) ?? + null + ) + } +} + +function execBridge( + platform: DesktopScriptPlatform, + scriptPath: string, + operationPath: string +): Promise<{ stdout: string; stderr: string }> { + const command = platform === 'windows' ? 'powershell.exe' : 'python3' + const args = + platform === 'windows' + ? [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + scriptPath, + operationPath + ] + : [scriptPath, operationPath] + return new Promise((resolve, reject) => { + execFile( + command, + args, + { + env: process.env, + maxBuffer: 20 * 1024 * 1024, + timeout: REQUEST_TIMEOUT_MS, + windowsHide: true + }, + (error, stdout, stderr) => { + if (error) { + const message = stderr.trim() || stdout.trim() || error.message + reject( + error.killed + ? new RuntimeClientError('action_timeout', message) + : mapBridgeError(message) + ) + return + } + resolve({ stdout, stderr }) + } + ) + }) +} + +function renderSnapshot(snapshot: BridgeSnapshot, noScreenshot: boolean): ComputerSnapshotResult { + const bounds = snapshot.windowBounds + const treeText = renderTreeText(snapshot) + const screenshot = snapshot.screenshotPngBase64 + ? { + data: snapshot.screenshotPngBase64, + format: 'png' as const, + width: Math.max(1, Math.round(bounds?.width ?? 1)), + height: Math.max(1, Math.round(bounds?.height ?? 1)), + scale: 1 + } + : null + return { + snapshot: { + // Why: bridge elements can be large; keep them cached internally for actions + // instead of returning duplicated metadata in every agent-facing snapshot. + id: snapshot.snapshotId ?? fallbackSnapshotId(snapshot), + app: { + name: snapshot.app.name, + bundleId: snapshot.app.bundleId ?? snapshot.app.bundleIdentifier ?? null, + pid: snapshot.app.pid + }, + window: { + title: snapshot.windowTitle ?? snapshot.app.name, + id: snapshot.windowId ?? null, + x: bounds ? Math.round(bounds.x) : null, + y: bounds ? Math.round(bounds.y) : null, + width: Math.max(0, Math.round(bounds?.width ?? 0)), + height: Math.max(0, Math.round(bounds?.height ?? 0)), + isMinimized: null, + isOffscreen: null, + screenIndex: null + }, + coordinateSpace: snapshot.coordinateSpace ?? 'window', + treeText, + elementCount: snapshot.elements?.length ?? 0, + focusedElementId: snapshot.focusedElementId ?? null, + truncation: { + truncated: snapshot.truncation?.truncated === true, + maxNodes: snapshot.truncation?.maxNodes, + maxDepth: snapshot.truncation?.maxDepth, + maxDepthReached: snapshot.truncation?.maxDepthReached === true + } + }, + screenshot, + screenshotStatus: screenshot + ? { + state: 'captured', + metadata: { engine: 'unknown', windowId: snapshot.windowId ?? null } + } + : noScreenshot + ? { state: 'skipped', reason: 'no_screenshot_flag' } + : { + state: 'failed', + code: 'screenshot_failed', + message: + 'desktop provider returned no image; grant screen capture permission or pass --no-screenshot to inspect accessibility state only.' + } + } +} + +function fallbackSnapshotId(snapshot: BridgeSnapshot): string { + const appRef = snapshot.app.bundleId ?? snapshot.app.bundleIdentifier ?? snapshot.app.name + return `${appRef}:${snapshot.app.pid}:${snapshot.windowId ?? 'window'}` +} + +function renderTreeText(snapshot: BridgeSnapshot): string { + const appRef = snapshot.app.bundleId ?? snapshot.app.bundleIdentifier ?? snapshot.app.name + const lines = [ + `App=${appRef} (pid ${snapshot.app.pid})`, + `Window: "${sanitize(snapshot.windowTitle ?? snapshot.app.name)}", App: ${sanitize(snapshot.app.name)}.`, + '', + ...(snapshot.treeLines ?? []) + ] + if (snapshot.selectedText) { + lines.push('', `Selected text: [${sanitize(snapshot.selectedText)}]`) + } else if (snapshot.focusedSummary) { + lines.push('', `The focused UI element is ${sanitize(snapshot.focusedSummary)}.`) + } + return lines.join('\n') +} + +function bridgeTool(method: NativeActionMethod): string { + return { + click: 'click', + performSecondaryAction: 'perform_secondary_action', + scroll: 'scroll', + drag: 'drag', + typeText: 'type_text', + pressKey: 'press_key', + hotkey: 'hotkey', + pasteText: 'paste_text', + setValue: 'set_value' + }[method] +} + +function actionCapabilityKey( + method: NativeActionMethod +): keyof ComputerProviderCapabilities['supports']['actions'] { + const keys = { + click: 'click', + performSecondaryAction: 'performAction', + scroll: 'scroll', + drag: 'drag', + typeText: 'typeText', + pressKey: 'pressKey', + hotkey: 'hotkey', + pasteText: 'pasteText', + setValue: 'setValue' + } satisfies Record + return keys[method] +} + +function desktopActionMetadata(method: NativeActionMethod, targetWindowId: number | null) { + const path = + method === 'pasteText' + ? ('clipboard' as const) + : method === 'setValue' || method === 'performSecondaryAction' + ? ('accessibility' as const) + : ('synthetic' as const) + return { + path, + actionName: + method === 'hotkey' + ? 'hotkey' + : method === 'pasteText' + ? 'paste' + : method === 'setValue' + ? 'setValue' + : method === 'performSecondaryAction' + ? 'performSecondaryAction' + : null, + fallbackReason: null, + targetWindowId, + verification: + method === 'hotkey' || method === 'pasteText' + ? { + state: 'unverified' as const, + reason: + method === 'pasteText' ? ('clipboard_paste' as const) : ('synthetic_input' as const) + } + : undefined + } +} + +function normalizeBridgeApp(app: BridgeResponse['app'] | BridgeWindow['app'] | undefined) { + if (!app) { + throw new RuntimeClientError('accessibility_error', 'desktop provider returned no app') + } + return { + name: app.name, + bundleId: app.bundleId ?? app.bundleIdentifier ?? null, + pid: app.pid + } +} + +function elementParam( + snapshot: BridgeSnapshot | null, + index: number | undefined +): BridgeElement | undefined { + if (index === undefined) { + return undefined + } + const element = snapshot?.elements?.find((candidate) => candidate.index === index) + if (!element) { + throw new RuntimeClientError( + 'element_not_found', + `element ${index} is not in the current cached snapshot; run get-app-state again and use a fresh element index` + ) + } + return element +} + +function snapshotKeysForWindow(query: string, snapshot: BridgeSnapshot): string[] { + return snapshot.windowId === null || snapshot.windowId === undefined + ? [] + : [snapshotWindowKey(query, snapshot.windowId)] +} + +function snapshotWindowKey(query: string, windowId: number): string { + return `${query.toLowerCase()}#window:${windowId}` +} + +function snapshotKeysForWindowIndex(query: string, params: Record): string[] { + const windowIndex = optionalNumberParam(params, 'windowIndex') + return windowIndex === undefined ? [] : [snapshotWindowIndexKey(query, windowIndex)] +} + +function snapshotWindowIndexKey(query: string, windowIndex: number): string { + return `${query.toLowerCase()}#window-index:${windowIndex}` +} + +function snapshotNamespace(params: Record): string { + const session = optionalStringParam(params, 'session') + const worktree = optionalStringParam(params, 'worktree') + return session ? `session:${session}` : worktree ? `worktree:${worktree}` : 'default' +} + +function namespacedSnapshotKey(namespace: string, key: string): string { + return `${namespace}:${key.toLowerCase()}` +} + +function isExplicitSnapshotNamespace(namespace: string): boolean { + return namespace !== 'default' +} + +function mapBridgeError(message: string): RuntimeClientError { + const text = message.trim() || 'desktop provider failed' + if (/appNotFound|app not found/i.test(text)) { + return new RuntimeClientError('app_not_found', text) + } + if (/appBlocked|app blocked/i.test(text)) { + return new RuntimeClientError('app_blocked', text) + } + if (/unsupported capability|hotkey.*require|paste_text requires/i.test(text)) { + return new RuntimeClientError('unsupported_capability', text) + } + if (/ModuleNotFoundError: No module named 'gi'|PyGObject|python3-gi/i.test(text)) { + return new RuntimeClientError( + 'unsupported_capability', + 'Linux Computer Use requires python3-gi and AT-SPI packages. Install python3-gi gir1.2-atspi-2.0 at-spi2-core, then retry.' + ) + } + if (/not a valid secondary action|action.*not supported/i.test(text)) { + return new RuntimeClientError('action_not_supported', text) + } + if (/value is not settable|not settable/i.test(text)) { + return new RuntimeClientError('value_not_settable', text) + } + if (/stale element|fresh element index/i.test(text)) { + return new RuntimeClientError('element_not_found', text) + } + if (/windowStale|window stale/i.test(text)) { + return new RuntimeClientError('window_stale', text) + } + if (/No top-level|No .*window|window/i.test(text)) { + return new RuntimeClientError('window_not_found', text) + } + if (/permission|desktop session|DBUS|XDG_RUNTIME_DIR|AT-SPI/i.test(text)) { + return new RuntimeClientError('permission_denied', text) + } + if (/element|element_index/i.test(text)) { + return new RuntimeClientError('element_not_found', text) + } + return new RuntimeClientError('accessibility_error', text) +} + +function requiredPlatform(): DesktopScriptPlatform { + const platform = desktopScriptPlatform() + if (!platform) { + throw new RuntimeClientError('accessibility_error', 'desktop script provider is not available') + } + return platform +} + +function requiredScriptPath(): string { + const scriptPath = resolveDesktopScriptProviderPath() + if (!scriptPath) { + throw new RuntimeClientError( + 'accessibility_error', + 'desktop script provider script was not found' + ) + } + return scriptPath +} + +function stringParam(params: Record, key: string): string { + const value = params[key] + if (typeof value !== 'string' || value.length === 0) { + throw new RuntimeClientError('invalid_argument', `missing ${key}`) + } + return value +} + +function optionalStringParam(params: Record, key: string): string | undefined { + const value = params[key] + return typeof value === 'string' ? value : undefined +} + +function optionalNumberParam(params: Record, key: string): number | undefined { + const value = params[key] + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function sanitize(value: string): string { + return value.replaceAll('\n', ' ').replaceAll('\r', ' ') +} diff --git a/src/main/computer/desktop-script-provider-paths.ts b/src/main/computer/desktop-script-provider-paths.ts new file mode 100644 index 00000000000..59931cf549b --- /dev/null +++ b/src/main/computer/desktop-script-provider-paths.ts @@ -0,0 +1,39 @@ +import { existsSync } from 'fs' +import { join, resolve } from 'path' + +export type DesktopScriptPlatform = 'linux' | 'windows' + +export function desktopScriptPlatform(): DesktopScriptPlatform | null { + if (process.platform === 'linux') { + return 'linux' + } + if (process.platform === 'win32') { + return 'windows' + } + return null +} + +export function resolveDesktopScriptProviderPath( + platform = desktopScriptPlatform() +): string | null { + const override = process.env.ORCA_COMPUTER_DESKTOP_SCRIPT_PROVIDER_PATH + if (override && existsSync(override)) { + return override + } + if (!platform) { + return null + } + + const filename = platform === 'windows' ? 'runtime.ps1' : 'runtime.py' + const directory = platform === 'windows' ? 'computer-use-windows' : 'computer-use-linux' + const sourceDirectory = + platform === 'windows' ? 'native/computer-use-windows' : 'native/computer-use-linux' + const packaged = [join(process.resourcesPath ?? '', directory, filename)] + const dev = [ + join(process.cwd(), sourceDirectory, filename), + resolve(__dirname, '../../', sourceDirectory, filename) + ] + const candidates = process.resourcesPath ? [...packaged, ...dev] : dev + + return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null +} diff --git a/src/main/computer/key-mapping.test.ts b/src/main/computer/key-mapping.test.ts new file mode 100644 index 00000000000..2604df8ab93 --- /dev/null +++ b/src/main/computer/key-mapping.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { parseKey } from './key-mapping' + +describe('parseKey', () => { + it.each([ + ['a', { key: 'a', modifiers: [] }], + ['7', { key: '7', modifiers: [] }], + [';', { key: ';', modifiers: [] }], + ['Return', { key: 'Enter', modifiers: [] }], + ['Escape', { key: 'Escape', modifiers: [] }], + ['BackSpace', { key: 'Backspace', modifiers: [] }], + ['Tab', { key: 'Tab', modifiers: [] }], + ['space', { key: 'Space', modifiers: [] }], + ['Page_Up', { key: 'PageUp', modifiers: [] }], + ['Page_Down', { key: 'PageDown', modifiers: [] }], + ['Home', { key: 'Home', modifiers: [] }], + ['End', { key: 'End', modifiers: [] }], + ['Up', { key: 'ArrowUp', modifiers: [] }], + ['Down', { key: 'ArrowDown', modifiers: [] }], + ['Left', { key: 'ArrowLeft', modifiers: [] }], + ['Right', { key: 'ArrowRight', modifiers: [] }], + ['Delete', { key: 'Delete', modifiers: [] }], + ['Insert', { key: 'Insert', modifiers: [] }], + ['F1', { key: 'F1', modifiers: [] }], + ['F24', { key: 'F24', modifiers: [] }], + ['KP_0', { key: 'Numpad0', modifiers: [] }], + ['KP_9', { key: 'Numpad9', modifiers: [] }], + ['KP_Add', { key: 'NumpadAdd', modifiers: [] }], + ['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }], + ['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }], + ['KP_Divide', { key: 'NumpadDivide', modifiers: [] }], + ['KP_Enter', { key: 'NumpadEnter', modifiers: [] }] + ])('maps %s', (input, expected) => { + expect(parseKey(input)).toEqual(expected) + }) + + it.each([ + ['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }], + ['control+a', { key: 'a', modifiers: ['Ctrl'] }], + ['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }], + ['alt+F4', { key: 'F4', modifiers: ['Alt'] }], + ['cmd+a', { key: 'a', modifiers: ['Meta'] }], + ['command+a', { key: 'a', modifiers: ['Meta'] }], + ['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }], + ['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }], + ['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }] + ])('maps chord %s', (input, expected) => { + expect(parseKey(input)).toEqual(expected) + }) + + it('rejects unknown modifiers and key names', () => { + expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) + expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) + expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) + }) +}) diff --git a/src/main/computer/key-mapping.ts b/src/main/computer/key-mapping.ts new file mode 100644 index 00000000000..cc6cb414ac1 --- /dev/null +++ b/src/main/computer/key-mapping.ts @@ -0,0 +1,109 @@ +import { RuntimeClientError } from './runtime-client-error' + +export type KeyChord = { + key: string + modifiers: string[] +} + +const MODIFIER_NAMES: Record = { + ctrl: 'Ctrl', + control: 'Ctrl', + shift: 'Shift', + alt: 'Alt', + meta: 'Meta', + super: 'Meta', + cmd: 'Meta', + command: 'Meta', + win: 'Meta' +} + +const PLATFORM_MODIFIER_NAMES: Record string> = { + cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'), + commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl') +} + +const KEY_NAMES: Record = { + Return: 'Enter', + Enter: 'Enter', + Escape: 'Escape', + Esc: 'Escape', + BackSpace: 'Backspace', + Backspace: 'Backspace', + Tab: 'Tab', + space: 'Space', + Space: 'Space', + Page_Up: 'PageUp', + Page_Down: 'PageDown', + Home: 'Home', + End: 'End', + Up: 'ArrowUp', + Down: 'ArrowDown', + Left: 'ArrowLeft', + Right: 'ArrowRight', + Delete: 'Delete', + Insert: 'Insert', + KP_Add: 'NumpadAdd', + KP_Subtract: 'NumpadSubtract', + KP_Multiply: 'NumpadMultiply', + KP_Divide: 'NumpadDivide', + KP_Enter: 'NumpadEnter' +} + +export function parseKey(input: string): KeyChord { + const parts = input + .split('+') + .map((part) => part.trim()) + .filter(Boolean) + if (parts.length === 0) { + throw new RuntimeClientError('invalid_argument', 'key must not be empty') + } + + const keyPart = parts.at(-1)! + const modifiers = parts.slice(0, -1).map(parseModifier) + return { + key: parseBaseKey(keyPart), + modifiers: dedupeModifiers(modifiers) + } +} + +function parseModifier(input: string): string { + const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()] + if (platformModifier) { + return platformModifier() + } + const modifier = MODIFIER_NAMES[input.toLowerCase()] + if (!modifier) { + throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`) + } + return modifier +} + +function parseBaseKey(input: string): string { + const mapped = KEY_NAMES[input] + if (mapped) { + return mapped + } + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) { + return input + } + const keypadDigit = input.match(/^KP_([0-9])$/) + if (keypadDigit) { + return `Numpad${keypadDigit[1]}` + } + if (isPrintableAscii(input)) { + return input + } + throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`) +} + +function isPrintableAscii(input: string): boolean { + if (input.length !== 1) { + return false + } + const code = input.charCodeAt(0) + return code >= 0x20 && code <= 0x7e +} + +function dedupeModifiers(modifiers: string[]): string[] { + return [...new Set(modifiers)] +} diff --git a/src/main/computer/macos-app-catalog.ts b/src/main/computer/macos-app-catalog.ts new file mode 100644 index 00000000000..273f5199fe4 --- /dev/null +++ b/src/main/computer/macos-app-catalog.ts @@ -0,0 +1,121 @@ +import { execFile } from 'child_process' +import type { ComputerAppInfo } from '../../shared/runtime-types' + +type MacOSRunningApp = ComputerAppInfo & { + activationPolicy: number + isFrontmost: boolean +} + +const JXA_LIST_RUNNING_APPS = ` +ObjC.import('AppKit'); +const apps = $.NSWorkspace.sharedWorkspace.runningApplications; +const out = []; +for (let i = 0; i < apps.count; i += 1) { + const app = apps.objectAtIndex(i); + if (app.isTerminated) continue; + const name = ObjC.unwrap(app.localizedName) || ''; + const bundleId = ObjC.unwrap(app.bundleIdentifier) || null; + const pid = Number(app.processIdentifier); + const activationPolicy = Number(app.activationPolicy); + if (!name || !Number.isInteger(pid) || pid <= 0) continue; + out.push({ + name, + bundleId, + pid, + activationPolicy, + isFrontmost: Boolean(app.active) + }); +} +JSON.stringify(out); +` + +export async function listMacOSApps(): Promise { + const { stdout } = await execFilePromise( + 'osascript', + ['-l', 'JavaScript', '-e', JXA_LIST_RUNNING_APPS], + { + encoding: 'utf8', + timeout: 5000, + maxBuffer: 1024 * 1024 + } + ) + const apps = parseMacOSApps(stdout).filter(hasLivePid) + return dedupeApps(apps.filter(isUserFacingApp)).map(({ name, bundleId, pid }) => ({ + name, + bundleId, + pid + })) +} + +function parseMacOSApps(stdout: string): MacOSRunningApp[] { + const parsed: unknown = JSON.parse(stdout) + if (!Array.isArray(parsed)) { + return [] + } + return parsed.flatMap((value): MacOSRunningApp[] => { + if (!value || typeof value !== 'object') { + return [] + } + const record = value as Record + const name = typeof record.name === 'string' ? record.name : '' + const bundleId = typeof record.bundleId === 'string' ? record.bundleId : null + const pid = typeof record.pid === 'number' && Number.isInteger(record.pid) ? record.pid : 0 + const activationPolicy = + typeof record.activationPolicy === 'number' && Number.isInteger(record.activationPolicy) + ? record.activationPolicy + : -1 + const isFrontmost = record.isFrontmost === true + return name && pid > 0 ? [{ name, bundleId, pid, activationPolicy, isFrontmost }] : [] + }) +} + +function isUserFacingApp(app: MacOSRunningApp): boolean { + return app.activationPolicy === 0 +} + +function hasLivePid(app: MacOSRunningApp): boolean { + try { + process.kill(app.pid, 0) + return true + } catch { + // NSWorkspace can briefly retain terminated apps; filter them before provider calls. + return false + } +} + +function dedupeApps(apps: MacOSRunningApp[]): MacOSRunningApp[] { + const seen = new Set() + const sorted = [...apps].sort((a, b) => { + if (a.isFrontmost !== b.isFrontmost) { + return a.isFrontmost ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + const result: MacOSRunningApp[] = [] + for (const app of sorted) { + const key = app.bundleId ? `bundle:${app.bundleId.toLowerCase()}` : `pid:${app.pid}` + if (seen.has(key)) { + continue + } + seen.add(key) + result.push(app) + } + return result +} + +function execFilePromise( + file: string, + args: string[], + options: { encoding: 'utf8'; timeout: number; maxBuffer: number } +): Promise<{ stdout: string }> { + return new Promise((resolve, reject) => { + execFile(file, args, options, (error, stdout, stderr) => { + if (error) { + const detail = typeof stderr === 'string' && stderr.trim() ? `: ${stderr.trim()}` : '' + reject(new Error(`${error.message}${detail}`)) + return + } + resolve({ stdout }) + }) + }) +} diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts new file mode 100644 index 00000000000..8345965c056 --- /dev/null +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -0,0 +1,165 @@ +import { execFileSync, spawn, spawnSync } from 'child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { openComputerUsePermissions } from './macos-computer-use-permissions' + +const resolveHelperAppPathMock = vi.hoisted(() => vi.fn()) +const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn()) + +vi.mock('child_process', () => ({ + execFileSync: vi.fn(), + spawn: vi.fn(() => ({ unref: vi.fn() })), + spawnSync: vi.fn() +})) + +vi.mock('./macos-native-provider-paths', () => ({ + resolveMacOSComputerUseAppPath: resolveHelperAppPathMock, + resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock +})) + +describe('openComputerUsePermissions', () => { + const originalPlatform = process.platform + + beforeEach(() => { + vi.mocked(spawn).mockClear() + vi.mocked(spawnSync).mockClear() + vi.mocked(execFileSync).mockReset() + resolveHelperAppPathMock.mockReset() + resolveHelperExecutablePathMock.mockReset() + resolveHelperExecutablePathMock.mockReturnValue( + '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos' + ) + mockPermissionStatus('{"accessibility":"granted","screenshots":"granted"}') + setPlatform('darwin') + }) + + afterEach(() => { + setPlatform(originalPlatform) + }) + + it('does not launch the setup helper when all permissions are granted', () => { + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + + expect(openComputerUsePermissions()).toEqual({ + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + permissionId: undefined, + openedSettings: false, + launchedHelper: false, + permissions: [ + { id: 'accessibility', status: 'granted' }, + { id: 'screenshots', status: 'granted' } + ], + nextStep: null + }) + expect(spawn).not.toHaveBeenCalled() + }) + + it('launches the helper app in permissions mode', () => { + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') + + expect(openComputerUsePermissions()).toEqual({ + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + permissionId: undefined, + openedSettings: false, + launchedHelper: true, + permissions: [ + { id: 'accessibility', status: 'granted' }, + { id: 'screenshots', status: 'not-granted' } + ], + nextStep: 'Grant Screen Recording to Orca Computer Use, then retry get-app-state.' + }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawnSync).toHaveBeenCalledWith( + '/usr/bin/pkill', + ['-f', 'orca-computer-use-macos --permission'], + { stdio: 'ignore' } + ) + expect(spawnSync).toHaveBeenCalledWith( + '/usr/bin/pkill', + ['-f', 'orca-computer-use-macos --permissions'], + { stdio: 'ignore' } + ) + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/open', + ['-n', '/Applications/Orca Computer Use.app', '--args', '--permissions'], + { detached: true, stdio: 'ignore' } + ) + }) + + it('launches a targeted permission helper flow', () => { + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + mockPermissionStatus('{"accessibility":"not-granted","screenshots":"not-granted"}') + + expect(openComputerUsePermissions('accessibility')).toEqual({ + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + permissionId: 'accessibility', + openedSettings: true, + launchedHelper: true, + permissions: [ + { id: 'accessibility', status: 'not-granted' }, + { id: 'screenshots', status: 'not-granted' } + ], + nextStep: 'Grant Accessibility to Orca Computer Use, then retry get-app-state.' + }) + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/open', + ['-n', '/Applications/Orca Computer Use.app', '--args', '--permission', 'accessibility'], + { detached: true, stdio: 'ignore' } + ) + }) + + it('returns a no-op result on non-macOS platforms', () => { + setPlatform('linux') + + expect(openComputerUsePermissions()).toEqual({ + platform: 'linux', + helperAppPath: null, + permissionId: undefined, + openedSettings: false, + launchedHelper: false, + permissions: [ + { id: 'accessibility', status: 'unsupported' }, + { id: 'screenshots', status: 'unsupported' } + ], + nextStep: null + }) + expect(spawn).not.toHaveBeenCalled() + }) + + it('throws when the helper app is missing on macOS', () => { + resolveHelperAppPathMock.mockReturnValue(null) + + expect(() => openComputerUsePermissions()).toThrow('Orca Computer Use.app was not found') + }) + + it('reads permission status through the helper app executable', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') + + expect(getComputerUsePermissionStatus()).toEqual({ + platform: 'darwin', + permissions: [ + { id: 'accessibility', status: 'granted' }, + { id: 'screenshots', status: 'not-granted' } + ] + }) + expect(execFileSync).toHaveBeenCalledWith( + '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos', + ['--permission-status'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ) + }) +}) + +function mockPermissionStatus(json: string): void { + vi.mocked(spawnSync).mockReturnValue({} as ReturnType) + vi.mocked(execFileSync).mockReturnValue(json) +} + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} diff --git a/src/main/computer/macos-computer-use-permissions.ts b/src/main/computer/macos-computer-use-permissions.ts new file mode 100644 index 00000000000..3de460eb29b --- /dev/null +++ b/src/main/computer/macos-computer-use-permissions.ts @@ -0,0 +1,133 @@ +import { execFileSync, spawn, spawnSync } from 'child_process' +import { RuntimeClientError } from './runtime-client-error' +import { + resolveMacOSComputerUseAppPath, + resolveMacOSComputerUseExecutablePath +} from './macos-native-provider-paths' +import type { + ComputerUsePermissionId, + ComputerUsePermissionSetupResult, + ComputerUsePermissionStatus, + ComputerUsePermissionStatusResult +} from '../../shared/computer-use-permissions-types' + +export function openComputerUsePermissions( + permissionId?: ComputerUsePermissionId +): ComputerUsePermissionSetupResult { + if (process.platform !== 'darwin') { + return { + platform: process.platform, + helperAppPath: null, + permissionId, + openedSettings: false, + launchedHelper: false, + permissions: [ + { id: 'accessibility', status: 'unsupported' }, + { id: 'screenshots', status: 'unsupported' } + ], + nextStep: null + } + } + + const helperAppPath = resolveMacOSComputerUseAppPath() + if (!helperAppPath) { + throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') + } + const status = getComputerUsePermissionStatus() + const nextStep = nextPermissionStep(status.permissions) + + if (!permissionId && !nextStep) { + return { + platform: process.platform, + helperAppPath, + permissionId, + openedSettings: false, + launchedHelper: false, + permissions: status.permissions, + nextStep + } + } + + closeExistingPermissionHelpers() + const helperArgs = permissionId ? ['--permission', permissionId] : ['--permissions'] + const helper = spawn('/usr/bin/open', ['-n', helperAppPath, '--args', ...helperArgs], { + detached: true, + stdio: 'ignore' + }) + helper.unref() + + return { + platform: process.platform, + helperAppPath, + permissionId, + openedSettings: permissionId !== undefined, + launchedHelper: true, + permissions: status.permissions, + nextStep + } +} + +function closeExistingPermissionHelpers(): void { + spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permission'], { + stdio: 'ignore' + }) + spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permissions'], { + stdio: 'ignore' + }) +} + +export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusResult { + if (process.platform !== 'darwin') { + return { + platform: process.platform, + permissions: [ + { id: 'accessibility', status: 'unsupported' }, + { id: 'screenshots', status: 'unsupported' } + ] + } + } + + const helperAppPath = resolveMacOSComputerUseAppPath() + if (!helperAppPath) { + throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') + } + + const raw = readPermissionStatusFromHelperApp(helperAppPath) + + return { + platform: process.platform, + permissions: [ + { id: 'accessibility', status: raw.accessibility ?? 'not-granted' }, + { id: 'screenshots', status: raw.screenshots ?? 'not-granted' } + ] + } +} + +function readPermissionStatusFromHelperApp( + helperAppPath: string +): Partial> { + const executablePath = resolveMacOSComputerUseExecutablePath() + if (!executablePath) { + throw new RuntimeClientError( + 'accessibility_error', + `${helperAppPath}/Contents/MacOS/orca-computer-use-macos was not found` + ) + } + // Why: launching the nested helper via LaunchServices can make TCC evaluate + // Orca.app as responsible; the signed helper executable owns this grant. + const output = execFileSync(executablePath, ['--permission-status'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + return JSON.parse(output) as Partial> +} + +function nextPermissionStep( + permissions: ComputerUsePermissionStatusResult['permissions'] +): string | null { + const missing = permissions.find((permission) => permission.status !== 'granted') + if (!missing) { + return null + } + return `Grant ${missing.id === 'accessibility' ? 'Accessibility' : 'Screen Recording'} to Orca Computer Use, then retry get-app-state.` +} diff --git a/src/main/computer/macos-native-provider-client.ts b/src/main/computer/macos-native-provider-client.ts new file mode 100644 index 00000000000..ec6b0c4a855 --- /dev/null +++ b/src/main/computer/macos-native-provider-client.ts @@ -0,0 +1,276 @@ +/* eslint-disable max-lines -- Why: the macOS provider transport owns one lifecycle across stdio fallback and helper-app socket mode. */ +import { spawn } from 'child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import type net from 'net' +import { release, tmpdir } from 'os' +import { join } from 'path' +import { randomUUID } from 'crypto' +import type { + ComputerActionResult, + ComputerListAppsResult, + ComputerListWindowsResult, + ComputerProviderCapabilities, + ComputerSnapshotResult +} from '../../shared/runtime-types' +import { + assertMacOSProviderCapability, + REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION, + type NativeActionMethod, + type NativeMethod, + type NativeResponse, + type PendingNativeRequest, + writeNativeProviderLine +} from './macos-native-provider-contract' +import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths' +import { connectMacOSProviderSocket } from './macos-native-provider-socket' +import { RuntimeClientError } from './runtime-client-error' + +const REQUEST_TIMEOUT_MS = 60_000 +const HELPER_CONNECT_TIMEOUT_MS = 10_000 + +export function shouldUseMacOSNativeProvider(): boolean { + return ( + process.platform === 'darwin' && + isMacOS14OrNewer() && + resolveMacOSComputerUseExecutablePath() !== null + ) +} + +export class MacOSNativeProviderClient { + private socket: net.Socket | null = null + private socketStartPromise: Promise | null = null + private socketPath: string | null = null + private socketDirectory: string | null = null + private socketTokenPath: string | null = null + private socketToken: string | null = null + private nextId = 1 + private pending = new Map() + private socketBuffer = '' + private providerCapabilities: ComputerProviderCapabilities | null = null + async listApps(): Promise { + return (await this.call('listApps', {})) as ComputerListAppsResult + } + async capabilities(): Promise { + await this.ensureCompatible() + return this.providerCapabilities! + } + async listWindows(params: unknown): Promise { + await this.ensureCapability('windows', 'list') + return (await this.call('listWindows', params)) as ComputerListWindowsResult + } + async snapshot(params: unknown): Promise { + return (await this.call('getAppState', params)) as ComputerSnapshotResult + } + async action(method: NativeActionMethod, params: unknown): Promise { + return (await this.call(method, params)) as ComputerActionResult + } + shutdown(): void { + const socket = this.socket + const token = this.socketToken + this.socket = null + this.socketStartPromise = null + this.providerCapabilities = null + if (socket && !socket.destroyed) { + const id = this.nextId++ + socket.write(`${JSON.stringify({ id, method: 'terminate', params: {}, token })}\n`) + socket.end() + } + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer) + pending.reject( + new RuntimeClientError('accessibility_error', 'native macOS provider shut down') + ) + this.pending.delete(id) + } + this.cleanupSocketDirectory() + } + private async call(method: NativeMethod, params: unknown): Promise { + if (method !== 'handshake') { + await this.ensureCompatible() + } + return await this.send(method, params) + } + private async send(method: NativeMethod, params: unknown): Promise { + const id = this.nextId++ + const helperExecutablePath = resolveMacOSComputerUseExecutablePath() + if (!helperExecutablePath) { + throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') + } + const transport = await this.ensureSocketStarted(helperExecutablePath) + const token = this.socketToken + const line = `${JSON.stringify({ id, method, params, token })}\n` + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id) + this.shutdown() + reject( + new RuntimeClientError('action_timeout', `native macOS provider ${method} timed out`) + ) + }, REQUEST_TIMEOUT_MS) + + this.pending.set(id, { resolve, reject, timer }) + }) + try { + await writeNativeProviderLine(transport, line) + } catch (error) { + const pending = this.pending.get(id) + if (pending) { + clearTimeout(pending.timer) + this.pending.delete(id) + } + throw new RuntimeClientError( + 'accessibility_error', + error instanceof Error ? error.message : String(error) + ) + } + return await result + } + private async ensureCompatible(): Promise { + if (this.providerCapabilities) { + return + } + const capabilities = await this.readCapabilities() + if (capabilities.protocolVersion === REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION) { + this.providerCapabilities = capabilities + return + } + this.shutdown() + const restarted = await this.readCapabilities() + if (restarted.protocolVersion !== REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION) { + throw new RuntimeClientError( + 'provider_incompatible', + `native macOS provider protocol ${restarted.protocolVersion} is incompatible with required protocol ${REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION}` + ) + } + this.providerCapabilities = restarted + } + private async readCapabilities(): Promise { + return (await this.send('handshake', {})) as ComputerProviderCapabilities + } + private async ensureCapability( + group: keyof ComputerProviderCapabilities['supports'], + capability: string + ): Promise { + await this.ensureCompatible() + if (assertMacOSProviderCapability(this.providerCapabilities, group, capability)) { + return + } + throw new RuntimeClientError( + 'unsupported_capability', + `native macOS provider does not support ${String(group)}.${capability}` + ) + } + private async ensureSocketStarted(helperExecutablePath: string): Promise { + if (this.socket && !this.socket.destroyed) { + return this.socket + } + if (this.socketStartPromise) { + return await this.socketStartPromise + } + this.socketStartPromise = this.startSocket(helperExecutablePath) + try { + return await this.socketStartPromise + } finally { + this.socketStartPromise = null + } + } + private async startSocket(helperExecutablePath: string): Promise { + this.socketDirectory = mkdtempSync(join(tmpdir(), 'orca-computer-use-')) + chmodSync(this.socketDirectory, 0o700) + this.socketPath = join(this.socketDirectory, 'provider.sock') + this.socketToken = randomUUID() + this.socketTokenPath = join(this.socketDirectory, 'provider.token') + writeFileSync(this.socketTokenPath, this.socketToken, { encoding: 'utf8', mode: 0o600 }) + // Why: launching the nested helper via LaunchServices can make TCC evaluate + // Orca.app as responsible; the signed helper executable owns this grant. + const provider = spawn( + helperExecutablePath, + ['--agent', this.socketPath, '--token-file', this.socketTokenPath], + { detached: true, stdio: 'ignore' } + ) + provider.unref() + try { + const socket = await connectMacOSProviderSocket(this.socketPath, HELPER_CONNECT_TIMEOUT_MS) + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => this.handleSocketData(chunk)) + socket.on('close', () => this.handleSocketClose()) + socket.on('error', (error) => this.handleTransportError(error)) + this.socket = socket + return socket + } catch (error) { + this.cleanupSocketDirectory() + this.socketPath = null + this.socketTokenPath = null + this.socketToken = null + throw error + } + } + private handleSocketData(chunk: string): void { + this.socketBuffer += chunk + this.socketBuffer = this.consumeLines(this.socketBuffer) + } + private consumeLines(buffer: string): string { + let remaining = buffer + while (true) { + const newline = remaining.indexOf('\n') + if (newline < 0) { + return remaining + } + const line = remaining.slice(0, newline) + remaining = remaining.slice(newline + 1) + if (line.trim()) { + this.handleLine(line) + } + } + } + private handleLine(line: string): void { + let response: NativeResponse + try { + response = JSON.parse(line) as NativeResponse + } catch { + return + } + const pending = this.pending.get(response.id) + if (!pending) { + return + } + clearTimeout(pending.timer) + this.pending.delete(response.id) + if (response.ok) { + pending.resolve(response.result) + return + } + pending.reject(new RuntimeClientError(response.error.code, response.error.message)) + } + private handleSocketClose(): void { + this.socket = null + this.cleanupSocketDirectory() + this.rejectPending( + new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed') + ) + } + private handleTransportError(error: Error): void { + this.rejectPending(new RuntimeClientError('accessibility_error', error.message)) + } + private cleanupSocketDirectory(): void { + if (!this.socketDirectory) { + return + } + rmSync(this.socketDirectory, { recursive: true, force: true }) + this.socketDirectory = null + this.socketPath = null + this.socketTokenPath = null + } + private rejectPending(error: Error): void { + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer) + pending.reject(error) + this.pending.delete(id) + } + } +} + +function isMacOS14OrNewer(): boolean { + const darwinMajor = Number.parseInt(release().split('.')[0] ?? '', 10) + return Number.isFinite(darwinMajor) && darwinMajor >= 23 +} diff --git a/src/main/computer/macos-native-provider-contract.ts b/src/main/computer/macos-native-provider-contract.ts new file mode 100644 index 00000000000..73e8341cf7f --- /dev/null +++ b/src/main/computer/macos-native-provider-contract.ts @@ -0,0 +1,56 @@ +import type net from 'net' +import type { ComputerProviderCapabilities } from '../../shared/runtime-types' + +export type NativeMethod = + | 'handshake' + | 'listApps' + | 'listWindows' + | 'getAppState' + | 'click' + | 'performSecondaryAction' + | 'scroll' + | 'drag' + | 'typeText' + | 'pressKey' + | 'hotkey' + | 'pasteText' + | 'setValue' + | 'terminate' + +export type NativeActionMethod = Exclude< + NativeMethod, + 'handshake' | 'listApps' | 'listWindows' | 'getAppState' | 'terminate' +> + +export type NativeResponse = + | { id: number; ok: true; result: unknown } + | { id: number; ok: false; error: { code: string; message: string } } + +export type PendingNativeRequest = { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: NodeJS.Timeout +} + +export const REQUIRED_MACOS_PROVIDER_PROTOCOL_VERSION = 1 + +export function assertMacOSProviderCapability( + capabilities: ComputerProviderCapabilities | null, + group: keyof ComputerProviderCapabilities['supports'], + capability: string +): boolean { + const groupCapabilities = capabilities?.supports[group] as Record | undefined + return groupCapabilities?.[capability] === true +} + +export function writeNativeProviderLine(transport: net.Socket, line: string): Promise { + return new Promise((resolve, reject) => { + transport.write(line, (error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} diff --git a/src/main/computer/macos-native-provider-paths.ts b/src/main/computer/macos-native-provider-paths.ts new file mode 100644 index 00000000000..54179ec413f --- /dev/null +++ b/src/main/computer/macos-native-provider-paths.ts @@ -0,0 +1,44 @@ +import { existsSync } from 'fs' +import { join, resolve } from 'path' + +export function resolveMacOSComputerUseAppPath(): string | null { + const override = process.env.ORCA_COMPUTER_MACOS_HELPER_APP_PATH + if (override && existsSync(override)) { + return override + } + + const packaged = [join(process.resourcesPath ?? '', 'Orca Computer Use.app')] + const dev = [ + join(process.cwd(), 'native/computer-use-macos/.build/release/Orca Computer Use.app'), + resolve(__dirname, '../../native/computer-use-macos/.build/release/Orca Computer Use.app') + ] + const candidates = process.resourcesPath ? [...packaged, ...dev] : dev + + return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null +} + +export function resolveMacOSComputerUseExecutablePath(): string | null { + const appPath = resolveMacOSComputerUseAppPath() + if (!appPath) { + return null + } + const executablePath = join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos') + return existsSync(executablePath) ? executablePath : null +} + +export function resolveMacOSNativeProviderPath(): string | null { + const override = process.env.ORCA_COMPUTER_MACOS_PROVIDER_PATH + if (override && existsSync(override)) { + return override + } + + const packaged = [join(process.resourcesPath ?? '', 'computer-use-macos/orca-computer-use-macos')] + const dev = [ + join(process.cwd(), 'native/computer-use-macos/.build/debug/orca-computer-use-macos'), + join(process.cwd(), 'native/computer-use-macos/.build/release/orca-computer-use-macos'), + resolve(__dirname, '../../native/computer-use-macos/.build/debug/orca-computer-use-macos') + ] + const candidates = process.resourcesPath ? [...packaged, ...dev] : dev + + return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null +} diff --git a/src/main/computer/macos-native-provider-socket.ts b/src/main/computer/macos-native-provider-socket.ts new file mode 100644 index 00000000000..021ab111794 --- /dev/null +++ b/src/main/computer/macos-native-provider-socket.ts @@ -0,0 +1,37 @@ +import net from 'net' +import { RuntimeClientError } from './runtime-client-error' + +export async function connectMacOSProviderSocket( + socketPath: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs + let lastError: Error | null = null + while (Date.now() < deadline) { + try { + return await connectSocket(socketPath) + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + throw new RuntimeClientError( + 'action_timeout', + `native macOS helper app did not open its socket: ${lastError?.message ?? 'timed out'}` + ) +} + +function connectSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath) + const onError = (error: Error) => { + socket.destroy() + reject(error) + } + socket.once('error', onError) + socket.once('connect', () => { + socket.off('error', onError) + resolve(socket) + }) + }) +} diff --git a/src/main/computer/permissions.ts b/src/main/computer/permissions.ts new file mode 100644 index 00000000000..cb7a7dd91ca --- /dev/null +++ b/src/main/computer/permissions.ts @@ -0,0 +1,51 @@ +import { Notification, shell, systemPreferences } from 'electron' + +const ACCESSIBILITY_SETTINGS_URL = + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' +const DEFAULT_ACCESSIBILITY_INSTRUCTIONS = + 'System Settings -> Privacy & Security -> Accessibility -> enable Orca' + +const activePermissionNotifications = new Set() + +/** Probe accessibility permissions; lazy -- invoked only on first failure path. */ +export async function checkAccessibilityPermission(): Promise<{ + ok: boolean + instructions?: string +}> { + if (process.platform !== 'darwin') { + return { ok: true } + } + + try { + const ok = systemPreferences.isTrustedAccessibilityClient(false) + return ok ? { ok: true } : { ok: false, instructions: DEFAULT_ACCESSIBILITY_INSTRUCTIONS } + } catch { + return { ok: false, instructions: DEFAULT_ACCESSIBILITY_INSTRUCTIONS } + } +} + +/** Surface a notification through Orca's existing notification system (do not duplicate UI). */ +export function notifyPermissionRequired(instructions: string): void { + if (!Notification.isSupported()) { + return + } + + const notification = new Notification({ + title: 'Accessibility permission required', + body: instructions + }) + activePermissionNotifications.add(notification) + + const release = (): void => { + activePermissionNotifications.delete(notification) + } + notification.on('close', release) + notification.on('click', () => { + release() + if (process.platform === 'darwin') { + void shell.openExternal(ACCESSIBILITY_SETTINGS_URL) + } + }) + setTimeout(release, 5 * 60 * 1000) + notification.show() +} diff --git a/src/main/computer/runtime-client-error.ts b/src/main/computer/runtime-client-error.ts new file mode 100644 index 00000000000..f0773c6cd8c --- /dev/null +++ b/src/main/computer/runtime-client-error.ts @@ -0,0 +1,9 @@ +export class RuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'RuntimeClientError' + this.code = code + } +} diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts new file mode 100644 index 00000000000..8981a0c6568 --- /dev/null +++ b/src/main/computer/sidecar-client.ts @@ -0,0 +1,227 @@ +import { fork, type ChildProcess } from 'child_process' +import { join } from 'path' +import type { + ComputerActionResult, + ComputerListAppsResult, + ComputerListWindowsResult, + ComputerProviderCapabilities, + ComputerSnapshotResult +} from '../../shared/runtime-types' +import { RuntimeClientError } from './runtime-client-error' + +type ComputerSidecarMethod = + | 'capabilities' + | 'listApps' + | 'listWindows' + | 'getAppState' + | 'click' + | 'performSecondaryAction' + | 'scroll' + | 'drag' + | 'typeText' + | 'pressKey' + | 'hotkey' + | 'pasteText' + | 'setValue' + +type ComputerSidecarRequest = { + id: number + method: ComputerSidecarMethod + params: unknown +} + +type ComputerSidecarResponse = + | { id: number; ok: true; result: unknown } + | { id: number; ok: false; error: { code: string; message: string } } + +type PendingRequest = { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: NodeJS.Timeout +} + +const REQUEST_TIMEOUT_MS = 60_000 +let sidecar: ComputerSidecarProcess | null = null + +export function shouldUseComputerSidecar(): boolean { + return ( + (process.platform === 'darwin' || + process.platform === 'linux' || + process.platform === 'win32') && + typeof process.versions.electron === 'string' && + process.env.ORCA_COMPUTER_SIDECAR !== '1' + ) +} + +export async function callComputerSidecarListApps(): Promise { + return (await getComputerSidecar().call('listApps', {})) as ComputerListAppsResult +} + +export async function callComputerSidecarCapabilities(): Promise { + return (await getComputerSidecar().call('capabilities', {})) as ComputerProviderCapabilities +} + +export async function callComputerSidecarListWindows( + params: unknown +): Promise { + return (await getComputerSidecar().call('listWindows', params)) as ComputerListWindowsResult +} + +export async function callComputerSidecarSnapshot( + params: unknown +): Promise { + return (await getComputerSidecar().call('getAppState', params)) as ComputerSnapshotResult +} + +export async function callComputerSidecarAction( + method: Exclude< + ComputerSidecarMethod, + 'capabilities' | 'listApps' | 'listWindows' | 'getAppState' + >, + params: unknown +): Promise { + return (await getComputerSidecar().call(method, params)) as ComputerActionResult +} + +export function resetComputerSidecarForTest(): void { + sidecar?.shutdown() + sidecar = null +} + +function getComputerSidecar(): ComputerSidecarProcess { + if (!sidecar) { + sidecar = new ComputerSidecarProcess(getComputerSidecarEntryPath()) + } + return sidecar +} + +function getComputerSidecarEntryPath(): string { + const app = loadElectronApp() + const appPath = app?.getAppPath() ?? process.cwd() + const isPackaged = app?.isPackaged ?? false + // Why: packaged sidecars must be forked from app.asar.unpacked because + // ELECTRON_RUN_AS_NODE bypasses Electron's asar require integration. + const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + return join(basePath, 'out', 'main', 'computer-sidecar.js') +} + +function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { + try { + return require('electron').app + } catch { + return null + } +} + +class ComputerSidecarProcess { + private child: ChildProcess | null = null + private nextId = 1 + private pending = new Map() + + constructor(private readonly entryPath: string) {} + + call(method: ComputerSidecarMethod, params: unknown): Promise { + const child = this.ensureStarted() + const id = this.nextId++ + const request: ComputerSidecarRequest = { id, method, params } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id) + this.shutdown() + reject(new RuntimeClientError('action_timeout', `computer sidecar ${method} timed out`)) + }, REQUEST_TIMEOUT_MS) + + this.pending.set(id, { resolve, reject, timer }) + child.send?.(request, (error) => { + if (!error) { + return + } + clearTimeout(timer) + this.pending.delete(id) + reject(new RuntimeClientError('accessibility_error', error.message)) + }) + }) + } + + shutdown(): void { + const child = this.child + this.child = null + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer) + pending.reject(new RuntimeClientError('accessibility_error', 'computer sidecar shut down')) + this.pending.delete(id) + } + child?.kill('SIGTERM') + } + + private ensureStarted(): ChildProcess { + if (this.child && !this.child.killed) { + return this.child + } + + const child = fork(this.entryPath, [], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_COMPUTER_SIDECAR: '1' + }, + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + + child.on('message', (message) => this.handleMessage(message)) + child.on('exit', (code, signal) => this.handleExit(code, signal)) + child.on('error', (error) => this.handleError(error)) + this.child = child + return child + } + + private handleMessage(message: unknown): void { + if (!isSidecarResponse(message)) { + return + } + const pending = this.pending.get(message.id) + if (!pending) { + return + } + clearTimeout(pending.timer) + this.pending.delete(message.id) + if (message.ok) { + pending.resolve(message.result) + return + } + pending.reject(new RuntimeClientError(message.error.code, message.error.message)) + } + + private handleExit(code: number | null, signal: NodeJS.Signals | null): void { + this.child = null + const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}` + const error = new RuntimeClientError( + 'accessibility_error', + `computer sidecar exited with ${detail}` + ) + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer) + pending.reject(error) + this.pending.delete(id) + } + } + + private handleError(error: Error): void { + const wrapped = new RuntimeClientError('accessibility_error', error.message) + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer) + pending.reject(wrapped) + this.pending.delete(id) + } + } +} + +function isSidecarResponse(message: unknown): message is ComputerSidecarResponse { + if (!message || typeof message !== 'object') { + return false + } + const record = message as Record + return typeof record.id === 'number' && typeof record.ok === 'boolean' +} diff --git a/src/main/computer/sidecar-entry.ts b/src/main/computer/sidecar-entry.ts new file mode 100644 index 00000000000..8b6ccb1e9ac --- /dev/null +++ b/src/main/computer/sidecar-entry.ts @@ -0,0 +1,137 @@ +/* eslint-disable max-lines -- Why: sidecar dispatch keeps one method table shared across native providers. */ +import { + MacOSNativeProviderClient, + shouldUseMacOSNativeProvider +} from './macos-native-provider-client' +import { + DesktopScriptProviderClient, + shouldUseDesktopScriptProvider +} from './desktop-script-provider-client' +import { RuntimeClientError } from './runtime-client-error' + +type SidecarRequest = { + id: number + method: string + params?: Record +} + +const nativeMacOSProvider = shouldUseMacOSNativeProvider() ? new MacOSNativeProviderClient() : null +const desktopScriptProvider = shouldUseDesktopScriptProvider() + ? new DesktopScriptProviderClient() + : null + +process.once('disconnect', shutdownProviders) +process.once('SIGTERM', () => { + shutdownProviders() + process.exit(0) +}) +process.once('SIGINT', () => { + shutdownProviders() + process.exit(130) +}) +process.once('beforeExit', shutdownProviders) + +process.on('message', (message: unknown) => { + void handleMessage(message) +}) + +async function handleMessage(message: unknown): Promise { + if (!isRequest(message)) { + return + } + + try { + const result = await dispatch(message.method, message.params ?? {}) + process.send?.({ id: message.id, ok: true, result }) + } catch (error) { + const mapped = errorToResponse(error) + process.send?.({ id: message.id, ok: false, error: mapped }) + } +} + +async function dispatch(method: string, params: Record): Promise { + const provider = nativeMacOSProvider ?? desktopScriptProvider + if (!provider) { + throw new RuntimeClientError( + 'unsupported_capability', + `computer-use has no native provider for ${process.platform}` + ) + } + + switch (method) { + case 'capabilities': { + return await provider.capabilities() + } + case 'listApps': { + return await provider.listApps() + } + case 'listWindows': { + return await provider.listWindows(params) + } + case 'getAppState': { + return await provider.snapshot(params) + } + case 'click': { + return await provider.action('click', params) + } + case 'performSecondaryAction': { + return await provider.action('performSecondaryAction', params) + } + case 'scroll': { + return await provider.action('scroll', params) + } + case 'drag': { + return await provider.action('drag', params) + } + case 'typeText': { + return await provider.action('typeText', params) + } + case 'pressKey': { + return await provider.action('pressKey', params) + } + case 'hotkey': { + return await provider.action('hotkey', params) + } + case 'pasteText': { + return await provider.action('pasteText', params) + } + case 'setValue': { + return await provider.action('setValue', params) + } + default: + throw new RuntimeClientError( + 'invalid_argument', + `unknown computer sidecar method '${method}'` + ) + } +} + +function isRequest(message: unknown): message is SidecarRequest { + if (!message || typeof message !== 'object') { + return false + } + const record = message as Record + return ( + typeof record.id === 'number' && + typeof record.method === 'string' && + (record.params === undefined || (typeof record.params === 'object' && record.params !== null)) + ) +} + +function errorToResponse(error: unknown): { code: string; message: string } { + if ( + error instanceof Error && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' + ) { + return { code: (error as { code: string }).code, message: error.message } + } + return { + code: 'accessibility_error', + message: error instanceof Error ? error.message : String(error) + } +} + +function shutdownProviders(): void { + nativeMacOSProvider?.shutdown() +} diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index ed7d479c798..9d45681d5ad 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -6,6 +6,7 @@ import { createManagedCommandMatcher, readHooksJson, removeManagedCommands, + wrapPosixHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -51,7 +52,7 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"` + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } function getManagedScript(): string { diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index b1a31e0a76d..ea3ff88bfee 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -156,6 +156,27 @@ describe('DaemonClient', () => { 'Something went wrong' ) }) + + it('adds recovery hints to node-pty daemon diagnostics', async () => { + await startMockDaemon({ + onControlMessage: (msg) => { + const req = msg as { id: string; type: string } + return encodeNdjson({ + id: req.id, + ok: false, + error: + "node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'" + }) + } + }) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + await expect(client.request('listSessions', undefined)).rejects.toThrow( + "Daemon's node-pty install is gone (worktree deleted?). Restart Orca. node-pty: posix_spawn failed: ENOENT" + ) + }) }) describe('events', () => { diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 946fcbc955d..007afd5ee68 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'crypto' import { encodeNdjson, createNdjsonParser } from './ndjson' import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types' import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types' +import { addNodePtyRecoveryHint } from './node-pty-error-hints' const CONNECT_TIMEOUT_MS = 5000 const REQUEST_TIMEOUT_MS = 30000 @@ -220,7 +221,9 @@ export class DaemonClient { if (response.ok) { resolve() } else { - reject(new DaemonProtocolError(response.error ?? 'Hello rejected')) + reject( + new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected')) + ) } } catch { reject(new DaemonProtocolError('Invalid hello response')) @@ -248,7 +251,7 @@ export class DaemonClient { if (response.ok) { pending.resolve(response.payload) } else { - pending.reject(new DaemonProtocolError(response.error)) + pending.reject(new DaemonProtocolError(addNodePtyRecoveryHint(response.error))) } } } diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index a4c9ef2068f..3140cc65993 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -120,22 +120,48 @@ describe('parseDaemonPidFile', () => { const serialized = serializeDaemonPidFile({ pid: 12345, startedAtMs: 1_700_000_000_000 }) expect(parseDaemonPidFile(serialized)).toEqual({ pid: 12345, - startedAtMs: 1_700_000_000_000 + startedAtMs: 1_700_000_000_000, + entryPath: null + }) + }) + + it('parses JSON pid files with entryPath', () => { + const serialized = serializeDaemonPidFile({ + pid: 12345, + startedAtMs: 1_700_000_000_000, + entryPath: '/repo/out/main/daemon-entry.js' + }) + expect(parseDaemonPidFile(serialized)).toEqual({ + pid: 12345, + startedAtMs: 1_700_000_000_000, + entryPath: '/repo/out/main/daemon-entry.js' }) }) it('accepts JSON with startedAtMs missing and returns null for it', () => { // Why: forward-compatible with hypothetical future daemons that might write // pid without startedAtMs (platform where getProcessStartedAtMs returns null). - expect(parseDaemonPidFile('{"pid":9999}')).toEqual({ pid: 9999, startedAtMs: null }) + expect(parseDaemonPidFile('{"pid":9999}')).toEqual({ + pid: 9999, + startedAtMs: null, + entryPath: null + }) }) it('falls back to bare-integer parsing for legacy pid files', () => { // Why: pre-Phase-0 daemons wrote the pid file as a bare integer. // parseDaemonPidFile must still accept those to avoid leaking a stale // daemon across a single upgrade boundary. - expect(parseDaemonPidFile('12345')).toEqual({ pid: 12345, startedAtMs: null }) - expect(parseDaemonPidFile(' 12345\n')).toEqual({ pid: 12345, startedAtMs: null }) + expect(parseDaemonPidFile('12345')).toEqual({ + pid: 12345, + startedAtMs: null, + entryPath: null + }) + expect(parseDaemonPidFile(' 12345\n')).toEqual({ + pid: 12345, + startedAtMs: null, + entryPath: null + }) }) it('returns null for malformed input', () => { diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 7d6126db9a5..f6d3670c80f 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -15,6 +15,7 @@ const START_TIME_TOLERANCE_MS = 1_500 type ParsedDaemonPid = { pid: number startedAtMs: number | null + entryPath: string | null } function canConnectSocket(socketPath: string): Promise { @@ -139,14 +140,19 @@ function commandLineMatchesDaemon( export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { const trimmed = contents.trim() try { - const parsed = JSON.parse(trimmed) as { pid?: unknown; startedAtMs?: unknown } + const parsed = JSON.parse(trimmed) as { + pid?: unknown + startedAtMs?: unknown + entryPath?: unknown + } if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) { return { pid: parsed.pid, startedAtMs: typeof parsed.startedAtMs === 'number' && Number.isFinite(parsed.startedAtMs) ? parsed.startedAtMs - : null + : null, + entryPath: typeof parsed.entryPath === 'string' ? parsed.entryPath : null } } } catch { @@ -154,7 +160,7 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { } const pid = Number(trimmed) - return Number.isFinite(pid) ? { pid, startedAtMs: null } : null + return Number.isFinite(pid) ? { pid, startedAtMs: null, entryPath: null } : null } function getLinuxProcessStartedAtMs(pid: number): number | null { @@ -278,6 +284,78 @@ function isDaemonProcess( } } +function getDaemonCommandLine(pid: number): string | null { + if (process.platform === 'win32') { + try { + return execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine` + ], + { + encoding: 'utf8', + timeout: 3_000 + } + ) + } catch { + return null + } + } + + try { + return readFileSync(`/proc/${pid}/cmdline`, 'utf8') + } catch { + try { + return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + timeout: 2_000 + }) + } catch { + return null + } + } +} + +export type DaemonLaunchIdentity = 'match' | 'mismatch' | 'unknown' + +export function getDaemonLaunchIdentity( + runtimeDir: string, + socketPath: string, + tokenPath: string, + expectedEntryPath: string, + protocolVersion = PROTOCOL_VERSION +): DaemonLaunchIdentity { + let parsedPid: ParsedDaemonPid | null + try { + parsedPid = parseDaemonPidFile( + readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') + ) + } catch { + return 'unknown' + } + + if (!parsedPid || !isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) { + return 'unknown' + } + + if (parsedPid.entryPath) { + return parsedPid.entryPath === expectedEntryPath ? 'match' : 'mismatch' + } + + // Why: older pid files did not persist entryPath. The command line still + // carries daemon-entry.js, so use it to stop dev worktrees from reusing a + // daemon forked from a deleted sibling checkout. If command-line probing is + // unavailable, fail open so we don't kill live sessions unnecessarily. + const commandLine = getDaemonCommandLine(parsedPid.pid) + if (!commandLine) { + return 'unknown' + } + return commandLine.includes(expectedEntryPath) ? 'match' : 'mismatch' +} + export async function killStaleDaemon( runtimeDir: string, socketPath: string, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 5b4cdfafc7c..82914e4875d 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -14,9 +14,12 @@ import { PROTOCOL_VERSION } from './types' // non-daemon-init dependency is replaced by a minimal stub that records calls. const { getPathMock, + isPackagedMock, probeSocketExistsMock, netConnectMock, + forkMock, healthCheckDaemonMock, + getDaemonLaunchIdentityMock, killStaleDaemonMock, getProcessStartedAtMsMock, daemonClientMock, @@ -27,8 +30,10 @@ const { rebindLocalProviderListenersMock } = vi.hoisted(() => { const getPathMock = vi.fn(() => '/fake/userData') + const isPackagedMock = vi.fn(() => false) const probeSocketExistsMock = vi.fn(() => false) + const forkMock = vi.fn() const netConnectMock = vi.fn(() => { // Why: the real probeSocket() in daemon-init connects to the socket and // resolves true on 'connect', false on 'error'. Our launcher never runs @@ -51,6 +56,7 @@ const { }) const healthCheckDaemonMock = vi.fn(async () => true) + const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const killStaleDaemonMock = vi.fn(async () => true) const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) @@ -73,9 +79,12 @@ const { return { getPathMock, + isPackagedMock, probeSocketExistsMock, netConnectMock, + forkMock, healthCheckDaemonMock, + getDaemonLaunchIdentityMock, killStaleDaemonMock, getProcessStartedAtMsMock, daemonClientMock, @@ -120,7 +129,13 @@ type MockAdapter = { } vi.mock('electron', () => ({ - app: { getPath: getPathMock, getAppPath: () => '/fake/app' } + app: { + get isPackaged() { + return isPackagedMock() + }, + getPath: getPathMock, + getAppPath: () => '/fake/app' + } })) vi.mock('fs', () => ({ @@ -130,11 +145,12 @@ vi.mock('fs', () => ({ writeFileSync: vi.fn() })) -vi.mock('child_process', () => ({ fork: vi.fn() })) +vi.mock('child_process', () => ({ fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ + getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, healthCheckDaemon: healthCheckDaemonMock, killStaleDaemon: killStaleDaemonMock, getProcessStartedAtMs: getProcessStartedAtMsMock @@ -224,7 +240,11 @@ async function importFresh() { unbindLocalProviderListenersMock.mockClear() rebindLocalProviderListenersMock.mockClear() healthCheckDaemonMock.mockClear() + getDaemonLaunchIdentityMock.mockClear() killStaleDaemonMock.mockClear() + forkMock.mockReset() + isPackagedMock.mockReset() + isPackagedMock.mockReturnValue(false) daemonClientMock.mockClear() probeSocketExistsMock.mockClear() // Why: importing daemon-init *after* resetModules means the module-level @@ -600,4 +620,74 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { 'restartDaemon called before initDaemonPtyProvider' ) }) + + it('respawns instead of reusing a healthy daemon launched from another app path', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') + forkMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token', + '/fake/app/out/main/daemon-entry.js' + ) + expect(killStaleDaemonMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalledWith( + '/fake/app/out/main/daemon-entry.js', + ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.objectContaining({ detached: true }) + ) + }) + + it('keeps packaged healthy-daemon reuse independent of dev app-path identity', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockClear() + killStaleDaemonMock.mockClear() + forkMock.mockClear() + isPackagedMock.mockReturnValue(true) + getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') + + await launcher('/fake/socket', '/fake/token') + + expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index d1545a81d0a..715701b60de 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -27,7 +27,12 @@ import { PROTOCOL_VERSION, type ListSessionsResult } from './types' -import { getProcessStartedAtMs, healthCheckDaemon, killStaleDaemon } from './daemon-health' +import { + getDaemonLaunchIdentity, + getProcessStartedAtMs, + healthCheckDaemon, + killStaleDaemon +} from './daemon-health' import { setLocalPtyProvider, unbindLocalProviderListeners, @@ -91,13 +96,26 @@ function probeSocket(socketPath: string): Promise { function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { return async (socketPath, tokenPath) => { + const entryPath = getDaemonEntryPath() const healthy = await healthCheckDaemon(socketPath, tokenPath) if (healthy) { - // Why: daemon is already running from a previous app session and - // responded to a protocol-level ping. Safe to reuse. - return { - shutdown: async () => { - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + // Why: dev worktrees share the same orca-dev userData, so a daemon from + // a deleted sibling checkout can pass protocol health checks while still + // pointing at missing native modules. Packaged app paths are stable and + // should preserve existing warm daemon reuse semantics. + const identity = app.isPackaged + ? 'match' + : getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) + if (identity === 'mismatch') { + console.warn('[daemon] Replacing daemon launched from a different app path') + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + } else { + // Why: daemon is already running from a previous app session and + // responded to a protocol-level ping. Safe to reuse. + return { + shutdown: async () => { + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + } } } } @@ -106,7 +124,6 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // before respawn so the new daemon does not race the stale process. await killStaleDaemon(runtimeDir, socketPath, tokenPath) - const entryPath = getDaemonEntryPath() const userDataPath = app.getPath('userData') const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { // Why: detached + unref lets the daemon outlive the Electron process. @@ -156,7 +173,8 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { getDaemonPidPath(runtimeDir), serializeDaemonPidFile({ pid: child.pid, - startedAtMs: getProcessStartedAtMs(child.pid) + startedAtMs: getProcessStartedAtMs(child.pid), + entryPath }), { mode: 0o600 } ) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index af8c7edac04..74b6e84d11b 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -129,7 +129,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { describe('shutdown', () => { it('kills the session', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) - await adapter.shutdown(id, false) + await adapter.shutdown(id, { immediate: false }) expect(lastSubprocess.kill).toHaveBeenCalled() }) }) @@ -312,7 +312,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('prevents spawn after shutdown for same sessionId', async () => { const sessionId = 'tombstone-test' await adapter.spawn({ cols: 80, rows: 24, sessionId }) - await adapter.shutdown(sessionId, true) + await adapter.shutdown(sessionId, { immediate: true }) await expect(adapter.spawn({ cols: 80, rows: 24, sessionId })).rejects.toThrow( 'was explicitly killed' @@ -321,7 +321,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('allows spawn for different sessionId after shutdown', async () => { await adapter.spawn({ cols: 80, rows: 24, sessionId: 'kill-me' }) - await adapter.shutdown('kill-me', true) + await adapter.shutdown('kill-me', { immediate: true }) const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'fresh-one' }) expect(result.id).toBe('fresh-one') @@ -330,7 +330,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('clearTombstone allows re-spawn', async () => { const sessionId = 'cleared-tombstone' await adapter.spawn({ cols: 80, rows: 24, sessionId }) - await adapter.shutdown(sessionId, true) + await adapter.shutdown(sessionId, { immediate: true }) adapter.clearTombstone(sessionId) @@ -349,7 +349,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const id = `evict-${i}` ids.push(id) await adapter.spawn({ cols: 80, rows: 24, sessionId: id }) - await adapter.shutdown(id, true) + await adapter.shutdown(id, { immediate: true }) } // All 5 should be tombstoned @@ -362,7 +362,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { // clearTombstone the first one, then re-kill it — it should still work adapter.clearTombstone(ids[0]) await adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] }) - await adapter.shutdown(ids[0], true) + await adapter.shutdown(ids[0], { immediate: true }) // First tombstone was re-added at the end of the Map, so eviction // order is now [evict-1, evict-2, evict-3, evict-4, evict-0] @@ -374,28 +374,32 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { describe('reconcileOnStartup', () => { it('returns alive sessions for valid worktrees', async () => { - await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-active' }) + const wt = 'repo-a::/wt/active' + await adapter.spawn({ cols: 80, rows: 24, worktreeId: wt }) - const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-active'])) + const { alive, killed } = await adapter.reconcileOnStartup(new Set([wt])) expect(alive).toHaveLength(1) - expect(alive[0]).toContain('wt-active') + expect(alive[0]).toContain(wt) expect(killed).toHaveLength(0) }) it('kills sessions for removed worktrees', async () => { - await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-removed' }) + const wt = 'repo-a::/wt/removed' + await adapter.spawn({ cols: 80, rows: 24, worktreeId: wt }) - const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-other'])) + const { alive, killed } = await adapter.reconcileOnStartup(new Set(['repo-a::/wt/other'])) expect(alive).toHaveLength(0) expect(killed).toHaveLength(1) - expect(killed[0]).toContain('wt-removed') + expect(killed[0]).toContain(wt) }) it('handles mix of valid and orphaned sessions', async () => { - await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-keep' }) - await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-delete' }) + const keep = 'repo-a::/wt/keep' + const drop = 'repo-a::/wt/delete' + await adapter.spawn({ cols: 80, rows: 24, worktreeId: keep }) + await adapter.spawn({ cols: 80, rows: 24, worktreeId: drop }) - const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-keep'])) + const { alive, killed } = await adapter.reconcileOnStartup(new Set([keep])) expect(alive).toHaveLength(1) expect(killed).toHaveLength(1) }) @@ -408,6 +412,22 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(alive).toHaveLength(1) expect(killed).toHaveLength(0) }) + + it('kills sessions whose id does not match the minted format, even if id is in valid set', async () => { + // Why: parsePtySessionId rejects bare UUIDs (no `@@`) and ids without + // the `::` worktree shape. Such sessions can't be attributed to any + // current worktree and must be treated as orphans regardless of + // valid-set membership. Passing the bare-uuid as a member of + // validWorktreeIds proves the new strict parser short-circuits the + // membership check — under the old loose parser this session would + // have been kept. + const sessionId = 'bare-uuid-no-separators' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + + const { alive, killed } = await adapter.reconcileOnStartup(new Set([sessionId])) + expect(alive).toHaveLength(0) + expect(killed).toHaveLength(1) + }) }) describe('dispose', () => { @@ -488,7 +508,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) - await historyAdapter.shutdown(id, true) + await historyAdapter.shutdown(id, { immediate: true }) await new Promise((r) => setTimeout(r, 50)) expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(false) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 349ca4d5136..7b752d717a3 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -6,7 +6,7 @@ import { existsSync } from 'fs' import { DaemonClient } from './client' import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' -import { mintPtySessionId } from './pty-session-id' +import { mintPtySessionId, parsePtySessionId } from './pty-session-id' import { supportsPtyStartupBarrier } from './shell-ready' import { PROTOCOL_VERSION, @@ -230,26 +230,31 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } - async shutdown(id: string, _immediate: boolean): Promise { + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.client.request('kill', { sessionId: id }) this.activeSessionIds.delete(id) this.initialCwds.delete(id) - // Why: user explicitly closed this terminal — clean up disk history - // so it doesn't trigger a false cold restore on next launch. - if (this.historyManager) { + // Why: history removal is for the "user explicitly closed this terminal" + // path. Sleep also calls shutdown but expects scrollback to survive — wake + // re-spawns and the cold-restore reader needs the dir intact. Caller + // indicates intent via opts.keepHistory. + if (this.historyManager && !opts.keepHistory) { void this.historyManager .removeSession(id) .catch((err) => console.warn('[history] removeSession failed:', id, err)) } - // Why: delete-then-set ensures the entry moves to the end of Map iteration - // order, so re-killing a session doesn't leave it as the first eviction target. - this.killedSessionTombstones.delete(id) - this.killedSessionTombstones.set(id, Date.now()) - if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { - const oldest = this.killedSessionTombstones.keys().next().value - if (oldest) { - this.killedSessionTombstones.delete(oldest) + // Why: tombstone rejects reattach against a session the user explicitly + // killed. Sleep legitimately reattaches on wake, so skip both the LRU bump + // and the size-cap eviction under keepHistory. + if (!opts.keepHistory) { + this.killedSessionTombstones.delete(id) + this.killedSessionTombstones.set(id, Date.now()) + if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { + const oldest = this.killedSessionTombstones.keys().next().value + if (oldest) { + this.killedSessionTombstones.delete(oldest) + } } } } @@ -325,14 +330,12 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!session.isAlive) { continue } - // Why: session IDs use the format `${worktreeId}@@${shortUuid}`. The @@ - // separator is unambiguous — worktreeIds contain hyphens and colons but - // never @@. - const separatorIdx = session.sessionId.lastIndexOf('@@') - const worktreeId = - separatorIdx !== -1 ? session.sessionId.slice(0, separatorIdx) : session.sessionId + // Why: session IDs use the format `${worktreeId}@@${shortUuid}`. Sessions + // whose id does not match the minted format (worktreeId === null) cannot + // be tied to a live worktree and are treated as orphans. + const { worktreeId } = parsePtySessionId(session.sessionId) - if (!validWorktreeIds.has(worktreeId)) { + if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { try { await this.client.request('kill', { sessionId: session.sessionId }) } catch { diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts index a773a0720a1..eb50820993d 100644 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ b/src/main/daemon/daemon-pty-provider.test.ts @@ -131,7 +131,7 @@ describe('DaemonPtyProvider', () => { describe('shutdown (kill session)', () => { it('kills the session in the daemon', async () => { await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.shutdown('s1', false) + await provider.shutdown('s1', { immediate: false }) expect(lastSubprocess.kill).toHaveBeenCalled() }) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts index 6380cc27f0b..74a5d834874 100644 --- a/src/main/daemon/daemon-pty-provider.ts +++ b/src/main/daemon/daemon-pty-provider.ts @@ -62,7 +62,7 @@ export class DaemonPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } - async shutdown(id: string, _immediate: boolean): Promise { + async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.client.request('kill', { sessionId: id }) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index d6ba74ef73e..b0e6c263aad 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -63,9 +63,17 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(id).resize(id, cols, rows) } - async shutdown(id: string, immediate: boolean): Promise { - await this.adapterFor(id).shutdown(id, immediate) - this.sessionAdapters.delete(id) + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + await this.adapterFor(id).shutdown(id, opts) + // Why: sleep passes keepHistory=true and re-spawns against the same + // sessionId on wake. If we delete the routing entry here, adapterFor() + // falls back to `this.current` on wake — for a session that originally + // lived on a legacy adapter (different protocolVersion), the wake-side + // createOrAttach lands on the wrong adapter and creates a fresh session, + // losing the cold-restore from the legacy adapter's history dir. + if (!opts.keepHistory) { + this.sessionAdapters.delete(id) + } } async sendSignal(id: string, signal: string): Promise { diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index d57b79e41f2..d40c0d39fd7 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -10,6 +10,7 @@ export type DaemonConnectionInfo = { export type DaemonPidFile = { pid: number startedAtMs: number | null + entryPath?: string } export type DaemonProcessHandle = { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 6491e435de5..12645ec1e0a 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -9,6 +9,10 @@ export type HeadlessEmulatorOptions = { scrollback?: number } +export type HeadlessSnapshotOptions = { + scrollbackRows?: number +} + const DEFAULT_SCROLLBACK = 5000 function parseFileUriPath(uri: string): string | null { @@ -87,10 +91,10 @@ export class HeadlessEmulator { this.terminal.resize(cols, rows) } - getSnapshot(): TerminalSnapshot { + getSnapshot(opts: HeadlessSnapshotOptions = {}): TerminalSnapshot { const modes = this.getModes() return { - snapshotAnsi: this.serializer.serialize(), + snapshotAnsi: this.serializer.serialize({ scrollback: opts.scrollbackRows }), scrollbackAnsi: '', rehydrateSequences: this.buildRehydrateSequences(modes), cwd: this.cwd, diff --git a/src/main/daemon/node-pty-error-hints.test.ts b/src/main/daemon/node-pty-error-hints.test.ts new file mode 100644 index 00000000000..1b36eada05a --- /dev/null +++ b/src/main/daemon/node-pty-error-hints.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { addNodePtyRecoveryHint, parseNodePtyDiagnostic } from './node-pty-error-hints' + +describe('node-pty diagnostic error hints', () => { + it('parses the native step and errno without dropping the original message', () => { + const message = + "node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/node-pty/spawn-helper'" + + expect(parseNodePtyDiagnostic(message)).toEqual({ step: 'posix_spawn', errno: 2 }) + expect(addNodePtyRecoveryHint(message)).toBe( + `Daemon's node-pty install is gone (worktree deleted?). Restart Orca. ${message}` + ) + }) + + it('hints when the daemon exhausts file descriptors opening the slave pty', () => { + const message = + "node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave='/dev/ttys003'" + + expect(addNodePtyRecoveryHint(message)).toBe( + `Daemon hit the file-descriptor limit. Restart the daemon. ${message}` + ) + }) + + it('hints when posix_spawn reports the per-user process limit', () => { + const message = + "node-pty: posix_spawn failed: EAGAIN (errno 35, Resource temporarily unavailable) - helper='/tmp/node-pty/spawn-helper'" + + expect(addNodePtyRecoveryHint(message)).toBe( + `Per-user process limit reached. Quit some agents and retry. ${message}` + ) + }) + + it('leaves unrelated and unhinted node-pty diagnostics unchanged', () => { + expect(addNodePtyRecoveryHint('plain failure')).toBe('plain failure') + expect( + addNodePtyRecoveryHint( + "node-pty: tcsetattr failed: EIO (errno 5, Input/output error) - slave='/dev/ttys003'" + ) + ).toBe("node-pty: tcsetattr failed: EIO (errno 5, Input/output error) - slave='/dev/ttys003'") + }) +}) diff --git a/src/main/daemon/node-pty-error-hints.ts b/src/main/daemon/node-pty-error-hints.ts new file mode 100644 index 00000000000..97c13baf5d9 --- /dev/null +++ b/src/main/daemon/node-pty-error-hints.ts @@ -0,0 +1,41 @@ +export type NodePtyDiagnostic = { + step: string + errno: number +} + +const NODE_PTY_DIAGNOSTIC_RE = /^node-pty: ([A-Za-z0-9_]+) failed: .*?\(errno (\d+)(?:, [^)]*)?\)/ + +export function parseNodePtyDiagnostic(message: string): NodePtyDiagnostic | null { + const match = NODE_PTY_DIAGNOSTIC_RE.exec(message) + if (!match) { + return null + } + + return { + step: match[1], + errno: Number(match[2]) + } +} + +export function getNodePtyRecoveryHint(diagnostic: NodePtyDiagnostic): string | null { + if (diagnostic.step === 'posix_spawn' && diagnostic.errno === 2) { + return "Daemon's node-pty install is gone (worktree deleted?). Restart Orca." + } + if (diagnostic.step === 'open_slave' && diagnostic.errno === 24) { + return 'Daemon hit the file-descriptor limit. Restart the daemon.' + } + if (diagnostic.step === 'posix_spawn' && diagnostic.errno === 35) { + return 'Per-user process limit reached. Quit some agents and retry.' + } + return null +} + +export function addNodePtyRecoveryHint(message: string): string { + const diagnostic = parseNodePtyDiagnostic(message) + if (!diagnostic) { + return message + } + + const hint = getNodePtyRecoveryHint(diagnostic) + return hint ? `${hint} ${message}` : message +} diff --git a/src/main/daemon/node-pty-fd-leak.test.ts b/src/main/daemon/node-pty-fd-leak.test.ts new file mode 100644 index 00000000000..0a7c3c1bfae --- /dev/null +++ b/src/main/daemon/node-pty-fd-leak.test.ts @@ -0,0 +1,92 @@ +import { execFileSync } from 'child_process' +import { existsSync, renameSync } from 'fs' +import { setTimeout as delay } from 'timers/promises' +import * as pty from 'node-pty' +import { describe, expect, it } from 'vitest' +import { getNodePtySpawnHelperCandidates } from '../providers/local-pty-utils' + +function currentRevokedFdCount(): number { + return execFileSync('lsof', ['-p', String(process.pid)], { encoding: 'utf8' }) + .split('\n') + .filter((line) => line.includes('(revoked)')).length +} + +function currentOpenFdCount(): number { + return execFileSync('lsof', ['-p', String(process.pid)], { encoding: 'utf8' }) + .split('\n') + .filter((line) => line.trim().length > 0).length +} + +function getExistingSpawnHelper(): string { + const helperPath = getNodePtySpawnHelperCandidates().find((candidate) => existsSync(candidate)) + expect(helperPath).toBeTruthy() + return helperPath as string +} + +async function spawnExitingPty(index: number): Promise { + const proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(index) } + }) + + await new Promise((resolve) => { + proc.onExit(() => resolve()) + }) + ;(proc as unknown as { destroy?: () => void }).destroy?.() +} + +const describeOnDarwin = process.platform === 'darwin' ? describe : describe.skip + +describeOnDarwin('node-pty macOS spawn fd handling', () => { + it('does not leak revoked slave tty fds across exited pty spawns', async () => { + const before = currentRevokedFdCount() + + for (let i = 0; i < 50; i++) { + await spawnExitingPty(i) + } + + await delay(500) + const after = currentRevokedFdCount() + + expect(after - before).toBe(0) + }, 15000) + + it('does not leak fds when native posix_spawn setup fails', async () => { + const helperPath = getExistingSpawnHelper() + const hiddenHelperPath = `${helperPath}.orca-test-hidden` + expect(existsSync(hiddenHelperPath)).toBe(false) + + const before = currentOpenFdCount() + renameSync(helperPath, hiddenHelperPath) + const restoreHelper = (): void => { + if (existsSync(hiddenHelperPath) && !existsSync(helperPath)) { + renameSync(hiddenHelperPath, helperPath) + } + } + process.on('exit', restoreHelper) + try { + for (let i = 0; i < 20; i++) { + expect(() => + pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(i) } + }) + ).toThrow(/node-pty: posix_spawn failed: ENOENT/) + } + } finally { + restoreHelper() + process.off('exit', restoreHelper) + } + + await delay(500) + const after = currentOpenFdCount() + + expect(after - before).toBe(0) + }, 15000) +}) diff --git a/src/main/daemon/pty-session-id.test.ts b/src/main/daemon/pty-session-id.test.ts index 93f7a4a84f3..da0bfd5ffba 100644 --- a/src/main/daemon/pty-session-id.test.ts +++ b/src/main/daemon/pty-session-id.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isSafePtySessionId, mintPtySessionId } from './pty-session-id' +import { isSafePtySessionId, mintPtySessionId, parsePtySessionId } from './pty-session-id' const USER_DATA = '/tmp/orca-userdata' @@ -76,3 +76,46 @@ describe('isSafePtySessionId', () => { expect(isSafePtySessionId('sub/path/ok@@12345678', USER_DATA)).toBe(true) }) }) + +describe('parsePtySessionId', () => { + it('round-trips a minted id back to its worktreeId', () => { + const wt = 'repo-abc::/Users/me/wt/feature' + expect(parsePtySessionId(mintPtySessionId(wt))).toEqual({ worktreeId: wt }) + }) + + it('rejects bare UUIDs (no @@)', () => { + expect(parsePtySessionId(mintPtySessionId())).toEqual({ worktreeId: null }) + }) + + it('rejects ids with @@ but no `::` worktree shape', () => { + // Why: callers use the returned worktreeId as a memory-attribution key. + // A non-minted id like `wt-only@@abcd1234` would synthesize a bogus + // worktreeId; require the canonical `${repoId}::${path}` shape. + expect(parsePtySessionId('wt-only@@abcd1234')).toEqual({ worktreeId: null }) + }) + + it('returns null for an empty string', () => { + expect(parsePtySessionId('')).toEqual({ worktreeId: null }) + }) + + it('handles worktreeIds whose path contains @ characters', () => { + // Why: the parser uses lastIndexOf('@@') so `@`-containing paths still + // round-trip cleanly as long as `@@` only appears as the separator. + const wt = 'repo::/Users/me/email@host/wt' + expect(parsePtySessionId(`${wt}@@deadbeef`)).toEqual({ worktreeId: wt }) + }) + + it('rejects degenerate `::@@…` ids with empty repo and path halves', () => { + // Why: `String.includes('::')` would accept '::' as a worktreeId. + // Memory-attribution callers must not bucket sessions under an empty key. + expect(parsePtySessionId('::@@deadbeef')).toEqual({ worktreeId: null }) + }) + + it('rejects ids with an empty path half (`repo::@@…`)', () => { + expect(parsePtySessionId('repo::@@deadbeef')).toEqual({ worktreeId: null }) + }) + + it('rejects ids with an empty repoId half (`::path@@…`)', () => { + expect(parsePtySessionId('::path@@deadbeef')).toEqual({ worktreeId: null }) + }) +}) diff --git a/src/main/daemon/pty-session-id.ts b/src/main/daemon/pty-session-id.ts index e1088341d44..fec681d1c19 100644 --- a/src/main/daemon/pty-session-id.ts +++ b/src/main/daemon/pty-session-id.ts @@ -1,5 +1,12 @@ import { randomUUID } from 'crypto' import { isAbsolute, join, relative, resolve, sep } from 'path' +import { PTY_SESSION_ID_SEPARATOR } from '../../shared/pty-session-id-format' + +// Why: re-exported here so main-side callers can keep importing +// `parsePtySessionId` from this module (next to `mintPtySessionId`). The +// implementation lives in `src/shared/` because the renderer-side merge +// helper also needs it and cannot import node-only modules. +export { parsePtySessionId } from '../../shared/pty-session-id-format' /** * Session IDs use the format `${worktreeId}@@${shortUuid}` so that @@ -12,7 +19,9 @@ import { isAbsolute, join, relative, resolve, sep } from 'path' * keying. */ export function mintPtySessionId(worktreeId?: string): string { - return worktreeId ? `${worktreeId}@@${randomUUID().slice(0, 8)}` : randomUUID() + return worktreeId + ? `${worktreeId}${PTY_SESSION_ID_SEPARATOR}${randomUUID().slice(0, 8)}` + : randomUUID() } /** diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 2580d98f4c7..c2a99aa1227 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1,5 +1,8 @@ /* oxlint-disable max-lines -- Why: exercises full PTY subprocess surface (spawn setup, signal routing, data events, platform-specific shell configs, and Windows PowerShell implementations) with co-located test scenarios to prevent fixture drift. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' const { spawnMock, isPwshAvailableMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), @@ -16,6 +19,15 @@ vi.mock('../pwsh', () => ({ import { createPtySubprocess } from './pty-subprocess' +const ORCA_SHELL_WRAPPER_ENV = [ + 'ORCA_ATTRIBUTION_SHIM_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_PI_CODING_AGENT_DIR' +] as const +const POWERSHELL_PROFILE_COMMAND = expect.stringMatching( + /ORCA_OPENCODE_CONFIG_DIR[\s\S]*ORCA_PI_CODING_AGENT_DIR[\s\S]*UTF8/ +) + function mockPtyProcess(pid = 12345) { const onDataListeners: ((data: string) => void)[] = [] const onExitListeners: ((e: { exitCode: number }) => void)[] = [] @@ -39,10 +51,38 @@ function mockPtyProcess(pid = 12345) { } describe('createPtySubprocess', () => { + const savedWrapperEnv: Partial> = {} + let previousUserDataPath: string | undefined + let userDataPath: string + beforeEach(() => { spawnMock.mockReset() isPwshAvailableMock.mockReset() isPwshAvailableMock.mockReturnValue(false) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + for (const key of ORCA_SHELL_WRAPPER_ENV) { + savedWrapperEnv[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + for (const key of ORCA_SHELL_WRAPPER_ENV) { + if (savedWrapperEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedWrapperEnv[key] + } + delete savedWrapperEnv[key] + } }) it('spawns node-pty with correct options', () => { const proc = mockPtyProcess() @@ -231,6 +271,48 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') }) + it('uses shell wrapper when OpenCode config must survive shell startup', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + SHELL: '/bin/zsh', + OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay', + ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay' + } + }) + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toContain('shell-ready/zsh') + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + }) + + it('uses shell wrapper when Pi config must survive shell startup', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + SHELL: '/bin/zsh', + PI_CODING_AGENT_DIR: '/tmp/orca-pi-agent-overlay', + ORCA_PI_CODING_AGENT_DIR: '/tmp/orca-pi-agent-overlay' + } + }) + + const lastCall = spawnMock.mock.calls.at(-1)! + expect(lastCall[1]).toEqual(['-l']) + expect(lastCall[2].env.ZDOTDIR).toContain('shell-ready/zsh') + expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') + }) + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -298,11 +380,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'powershell.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -331,11 +409,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'pwsh.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -364,11 +438,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'powershell.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -397,11 +467,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'powershell.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index bdeec2053a2..213a0d6ce0f 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,13 +1,18 @@ import * as pty from 'node-pty' +import { statSync } from 'fs' import { win32 as pathWin32 } from 'path' import type { SubprocessHandle } from './session' +import { DaemonProtocolError } from './types' import { getAttributionShellLaunchConfig, getShellReadyLaunchConfig, resolvePtyShellPath } from './shell-ready' import { isValidPtySize, normalizePtySize } from './daemon-pty-size' -import { ensureNodePtySpawnHelperExecutable } from '../providers/local-pty-utils' +import { + ensureNodePtySpawnHelperExecutable, + getNodePtySpawnHelperCandidates +} from '../providers/local-pty-utils' import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args' import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershell' import { isPwshAvailable } from '../pwsh' @@ -43,6 +48,54 @@ function getDefaultCwd(): string { return 'C:\\' } +function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { + const detailName = kind === 'helper' ? 'helper' : 'cwd' + const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' + return new DaemonProtocolError( + `Daemon's ${kind === 'helper' ? 'node-pty install' : 'working directory'} is gone ` + + `(worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT ` + + `(errno 2, No such file or directory) - ${detailName}='${path}'` + ) +} + +function preflightMacNodePtySpawnEnvironment(): void { + if (process.platform !== 'darwin') { + return + } + + let daemonCwd: string + try { + daemonCwd = process.cwd() + if (!statSync(daemonCwd).isDirectory()) { + throw formatMissingDaemonPathError('cwd', daemonCwd) + } + } catch (error) { + if (error instanceof DaemonProtocolError) { + throw error + } + throw formatMissingDaemonPathError('cwd', '') + } + + let candidates: string[] + try { + candidates = getNodePtySpawnHelperCandidates() + } catch { + throw formatMissingDaemonPathError('helper', '') + } + + for (const candidate of candidates) { + try { + if (statSync(candidate).isFile()) { + return + } + } catch { + // Try the next node-pty native location. + } + } + + throw formatMissingDaemonPathError('helper', candidates[0] ?? '') +} + export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle { const size = normalizePtySize(opts.cols, opts.rows) const env: Record = { @@ -106,9 +159,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd } else { + // Why: any Orca-injected overlay env that user rc files can clobber + // needs the wrapper so the post-rc restore line runs. const shellLaunch = opts.command ? getShellReadyLaunchConfig(shellPath) - : env.ORCA_ATTRIBUTION_SHIM_DIR + : env.ORCA_ATTRIBUTION_SHIM_DIR || + env.ORCA_OPENCODE_CONFIG_DIR || + env.ORCA_PI_CODING_AGENT_DIR ? getAttributionShellLaunchConfig(shellPath) : null if (shellLaunch) { @@ -121,6 +178,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // binary. The main process fixes this via LocalPtyProvider, but the daemon // runs in a separate forked process with its own code path. ensureNodePtySpawnHelperExecutable() + preflightMacNodePtySpawnEnvironment() const proc = pty.spawn(shellPath, shellArgs, { name: 'xterm-256color', diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 7f957072ccd..951c83e877e 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -13,10 +13,13 @@ const describePosix = process.platform === 'win32' ? describe.skip : describe describePosix('daemon shell-ready launch config', () => { let previousUserDataPath: string | undefined + let previousOrcaOrigZdotdir: string | undefined let userDataPath: string beforeEach(() => { previousUserDataPath = process.env.ORCA_USER_DATA_PATH + previousOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR + delete process.env.ORCA_ORIG_ZDOTDIR userDataPath = mkdtempSync(join(tmpdir(), 'daemon-shell-ready-test-')) process.env.ORCA_USER_DATA_PATH = userDataPath }) @@ -27,6 +30,11 @@ describePosix('daemon shell-ready launch config', () => { } else { process.env.ORCA_USER_DATA_PATH = previousUserDataPath } + if (previousOrcaOrigZdotdir === undefined) { + delete process.env.ORCA_ORIG_ZDOTDIR + } else { + process.env.ORCA_ORIG_ZDOTDIR = previousOrcaOrigZdotdir + } rmSync(userDataPath, { recursive: true, force: true }) vi.restoreAllMocks() }) @@ -159,6 +167,27 @@ describePosix('daemon shell-ready launch config', () => { expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) + it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => { + const { getShellReadyLaunchConfig } = await importFreshShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + getShellReadyLaunchConfig('/bin/bash') + + const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') + const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') + const restoreLine = + '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' + const piRestoreLine = + '[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' + expect(zshrc).toContain(restoreLine) + expect(zlogin).toContain(restoreLine) + expect(bashRc).toContain(restoreLine) + expect(zshrc).toContain(piRestoreLine) + expect(zlogin).toContain(piRestoreLine) + expect(bashRc).toContain(piRestoreLine) + }) + it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => { // Why: users who run a custom zsh dotfiles directory legitimately set // ZDOTDIR before launching Orca. We only want to reject the self-loop diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index f7a2e021fdb..5ae1aa58d80 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -112,7 +112,13 @@ __orca_restore_attribution_path() { esac export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } -[[ ! -o login ]] && __orca_restore_attribution_path +if [[ ! -o login ]]; then + __orca_restore_attribution_path + # Why: ~/.zshrc can export the user's default OpenCode config after spawn. + [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" + # Why: PI_CODING_AGENT_DIR must keep the same PTY-scoped overlay after rc files. + [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +fi ` const zshLogin = `# Orca daemon zsh shell-ready wrapper _orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" @@ -130,11 +136,18 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path +# Why: .zlogin is the final login startup file before the prompt is shown. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER}" } - precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) + # Why: zsh precmd fires before zle switches the PTY into line-editing mode, + # so writing startup input there can be echoed once outside the prompt. + autoload -Uz add-zle-hook-widget + zle -N __orca_prompt_mark + add-zle-hook-widget line-init __orca_prompt_mark fi ` const bashRc = `# Orca daemon bash shell-ready wrapper @@ -154,6 +167,11 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path +# Why: user startup files may set the default OpenCode config after Orca's +# spawn env; restore the PTY-scoped overlay before the first prompt. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +# Why: PI_CODING_AGENT_DIR is also a single-root env var users may re-export. +[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER}" diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a7fba8f42ca..20fda41f02a 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,11 +1,10 @@ // ─── Protocol Version ──────────────────────────────────────────────── -// Why: daemons can survive app updates with long-lived shell env. Bump when -// spawn-time env semantics change so stale sessions cannot bypass new behavior. -// Why: bumped from 3 → 4 for the getSnapshot RPC. A surviving v3 daemon -// would reject getSnapshot as unknown, silently failing all checkpoint -// writes. The bump forces a stale daemon to be replaced on reconnect. -export const PROTOCOL_VERSION = 4 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3] as const +// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or +// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. +// Why: bumped from 4 → 5 for OpenCode/Pi overlay restoration: a surviving v4 +// daemon keeps emitting stale shell-ready wrappers and stale PowerShell args. +export const PROTOCOL_VERSION = 5 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4] as const // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' diff --git a/src/main/dock/unread-badge.ts b/src/main/dock/unread-badge.ts new file mode 100644 index 00000000000..e9786022817 --- /dev/null +++ b/src/main/dock/unread-badge.ts @@ -0,0 +1,14 @@ +import { app } from 'electron' + +export function setUnreadDockBadgeCount(count: number): void { + if (process.platform !== 'darwin') { + return + } + + const normalizedCount = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0 + const label = normalizedCount === 0 ? '' : normalizedCount > 99 ? '99+' : String(normalizedCount) + + // Why: unread counts belong on the native Dock tile on macOS. + // Windows/Linux are skipped until we define the right platform behavior. + app.dock?.setBadge(label) +} diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 4239460bd4c..40e4fb5d2fe 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -6,6 +6,7 @@ import { createManagedCommandMatcher, readHooksJson, removeManagedCommands, + wrapPosixHookCommand, writeHooksJson, writeManagedScript, type HookDefinition @@ -37,7 +38,7 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"` + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } function getManagedScript(): string { diff --git a/src/main/git/commit.test.ts b/src/main/git/commit.test.ts new file mode 100644 index 00000000000..465488d268f --- /dev/null +++ b/src/main/git/commit.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: vi.fn() +})) + +import { commitChanges } from './status' + +describe('commitChanges', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('returns success when git commit completes', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '[main abc123] message\n', stderr: '' }) + + const result = await commitChanges('/repo', 'feat: add commit action') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['commit', '-m', 'feat: add commit action'], { + cwd: '/repo' + }) + expect(result).toEqual({ success: true }) + }) + + it('returns stderr when commit fails (e.g. pre-commit hook)', async () => { + gitExecFileAsyncMock.mockRejectedValue({ + stderr: 'pre-commit hook failed: lint errors\n' + }) + + const result = await commitChanges('/repo', 'feat: commit with lint errors') + + expect(result).toEqual({ + success: false, + error: 'pre-commit hook failed: lint errors\n' + }) + }) + + it('returns stdout when git writes to stdout (e.g. nothing to commit)', async () => { + gitExecFileAsyncMock.mockRejectedValue({ + stdout: 'nothing to commit, working tree clean\n', + stderr: '' + }) + + const result = await commitChanges('/repo', 'feat: empty commit') + + expect(result).toEqual({ + success: false, + error: 'nothing to commit, working tree clean\n' + }) + }) + + it('prefers stderr over stdout when both are present', async () => { + gitExecFileAsyncMock.mockRejectedValue({ + stdout: 'some stdout output\n', + stderr: 'hook rejected\n' + }) + + const result = await commitChanges('/repo', 'feat: both channels') + + expect(result).toEqual({ + success: false, + error: 'hook rejected\n' + }) + }) + + it('falls back to Error.message when stdout/stderr are empty', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('spawn git ENOENT')) + + const result = await commitChanges('/repo', 'feat: missing git') + + expect(result).toEqual({ + success: false, + error: 'spawn git ENOENT' + }) + }) +}) diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts new file mode 100644 index 00000000000..4d3f626f8a5 --- /dev/null +++ b/src/main/git/remote.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { gitFetch, gitPull, gitPush } from './remote' + +describe('git remote operations', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('pushes with --set-upstream regardless of publish flag', async () => { + // Why: every push uses --set-upstream so worktrees that were created + // tracking the BASE ref (origin/main) get their upstream repointed to + // origin/ on first push. Without that the local branch keeps + // tracking origin/main forever and the UI's ahead/behind read via + // @{u} measures "ahead of base" rather than "ahead of remote branch". + // Both publish=true and publish=false take the same path now; the + // parameter is preserved in the signature for IPC compatibility but + // is no longer load-bearing. + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await gitPush('/repo', true) + await gitPush('/repo', false) + + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['push', '--set-upstream', 'origin', 'HEAD'], + { cwd: '/repo' } + ) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['push', '--set-upstream', 'origin', 'HEAD'], + { cwd: '/repo' } + ) + }) + + it('maps non-fast-forward push failures to an actionable message', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('remote rejected: non-fast-forward')) + + await expect(gitPush('/repo', false)).rejects.toThrow( + 'Push rejected: remote has newer commits (non-fast-forward). Please pull or sync first.' + ) + }) + + it('passes through clean tail line when push error does not match known patterns', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce( + new Error('Command failed: git push\nfatal: something obscure happened') + ) + + await expect(gitPush('/repo', false)).rejects.toThrow('fatal: something obscure happened') + }) + + it('strips embedded credentials from push error messages', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce( + new Error( + 'Command failed: git push\nhttps://x-access-token:ghp_abc@github.com/foo/bar.git\nfatal: remote error' + ) + ) + + let caught: Error | undefined + try { + await gitPush('/repo', false) + } catch (error) { + caught = error as Error + } + + expect(caught).toBeInstanceOf(Error) + expect(caught?.message).not.toContain('ghp_abc') + expect(caught?.message).not.toContain('x-access-token') + }) + + it('strips token-only credentials (https://TOKEN@host) from push error messages', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce( + new Error( + 'Command failed: git push\nhttps://ghp_onlyToken@github.com/foo/bar.git\nfatal: remote error' + ) + ) + + let caught: Error | undefined + try { + await gitPush('/repo', false) + } catch (error) { + caught = error as Error + } + + expect(caught).toBeInstanceOf(Error) + expect(caught?.message).not.toContain('ghp_onlyToken') + }) + + it('falls back to a generic message for non-Error rejections', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce('string') + + await expect(gitPush('/repo', false)).rejects.toThrow('Git remote operation failed.') + }) + + it("runs pull with the user's configured strategy", async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await gitPull('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['pull'], { cwd: '/repo' }) + }) + + it('normalizes pull authentication errors to a friendly message', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('Authentication failed')) + + await expect(gitPull('/repo')).rejects.toThrow( + 'Authentication failed. Check your remote credentials.' + ) + }) + + it('runs fetch with prune', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await gitFetch('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', '--prune'], { cwd: '/repo' }) + }) + + it('normalizes fetch authentication errors to a friendly message', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('Authentication failed')) + + await expect(gitFetch('/repo')).rejects.toThrow( + 'Authentication failed. Check your remote credentials.' + ) + }) +}) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts new file mode 100644 index 00000000000..2d26c5c75b1 --- /dev/null +++ b/src/main/git/remote.ts @@ -0,0 +1,46 @@ +import { normalizeGitErrorMessage } from '../../shared/git-remote-error' +import { gitExecFileAsync } from './runner' + +export async function gitPush(worktreePath: string, _publish = false): Promise { + try { + // Why: always pass --set-upstream so that worktrees Orca creates with + // `git worktree add --track -b ` (which initially + // track the BASE — e.g. origin/main) get their upstream repointed to + // origin/ on first push. Without this the local branch keeps + // tracking origin/main forever, so ahead/behind reads via @{u} measure + // "ahead of base" rather than "ahead of remote branch", and the primary + // button never rotates from "Push" to "Commit" after a successful push. + // + // The `publish` flag becomes redundant under this strategy — every push + // sets upstream, including the first. We keep the parameter in the + // signature so callers don't need to change, but it's no longer + // load-bearing. On an already-published branch --set-upstream is a + // no-op for the tracking config and a regular push otherwise. + // + // Branch-vs-base reporting (the "Committed on Branch" section) is + // unaffected because it uses branchCompare against an explicit baseRef + // from worktree config, not the upstream relationship. + await gitExecFileAsync(['push', '--set-upstream', 'origin', 'HEAD'], { cwd: worktreePath }) + } catch (error) { + throw new Error(normalizeGitErrorMessage(error, 'push')) + } +} + +export async function gitPull(worktreePath: string): Promise { + // Why: plain `git pull` uses the user's configured pull strategy (merge by + // default) so diverged branches reconcile instead of erroring out. Conflicts + // surface through the existing conflict-resolution flow. + try { + await gitExecFileAsync(['pull'], { cwd: worktreePath }) + } catch (error) { + throw new Error(normalizeGitErrorMessage(error, 'pull')) + } +} + +export async function gitFetch(worktreePath: string): Promise { + try { + await gitExecFileAsync(['fetch', '--prune'], { cwd: worktreePath }) + } catch (error) { + throw new Error(normalizeGitErrorMessage(error, 'fetch')) + } +} diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 97f8eee820b..f6c93000cbe 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -87,7 +87,7 @@ describe('removeWorktree', () => { it('removes the worktree, prunes stale refs, and deletes its local branch', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -97,7 +97,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -121,7 +121,7 @@ branch refs/heads/main it('skips branch deletion when another worktree still points at the branch', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -135,7 +135,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -154,7 +154,7 @@ branch refs/heads/feature/test expect.arrayContaining([ 'git worktree remove /repo-feature', 'git worktree prune', - 'git worktree list --porcelain -z' + 'git worktree list --porcelain' ]) ) expect(calls).not.toContain('git branch -D feature/test') @@ -163,7 +163,7 @@ branch refs/heads/feature/test it('deletes the branch after prune removes stale sibling worktree entries', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -178,7 +178,7 @@ branch refs/heads/feature/test prunable gitdir file points to non-existent location ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -201,7 +201,7 @@ branch refs/heads/main it('passes --force before the worktree path when forced removal is requested', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -211,7 +211,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -226,7 +226,7 @@ branch refs/heads/main it('matches Windows worktree paths before deleting the branch', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree C:/repo HEAD abc123 branch refs/heads/main @@ -236,7 +236,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree C:/repo HEAD abc123 branch refs/heads/main @@ -259,7 +259,7 @@ branch refs/heads/main it('keeps removal successful when branch cleanup fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -269,7 +269,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -306,16 +306,15 @@ describe('listWorktrees', () => { resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) }) - it('translates parsed path fields from NUL-delimited porcelain output', async () => { + it('translates parsed path fields from line-block porcelain output', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: - 'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' + - 'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0sparse\0\0' - }) - translateWslOutputPathsMock.mockImplementation((output: string) => { - expect(output).not.toContain('\0') - return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + 'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' + + 'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\nsparse\n\n' }) + translateWslOutputPathsMock.mockImplementation((output: string) => + output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + ) await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([ { @@ -337,27 +336,47 @@ describe('listWorktrees', () => { // Why: the non-sparse main worktree gets an fs probe of its sparse config // file; the linked worktree short-circuits on the parsed `sparse` token and // does not. Only one git subprocess runs regardless of worktree count. - expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) + expect(getGitCalls()).toEqual(['git worktree list --porcelain']) expect(statMock).toHaveBeenCalledTimes(1) expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) }) + it('returns no worktrees when the repo path is gone', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + gitExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('spawn git ENOENT'), { + code: 'ENOENT' + }) + ) + statMock.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + await expect(listWorktrees('/workspace/deleted-repo')).resolves.toEqual([]) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], { + cwd: '/workspace/deleted-repo' + }) + expect(statMock).toHaveBeenCalledWith('/workspace/deleted-repo') + expect(warnSpy).toHaveBeenCalledWith( + '[git/worktree] repo path missing; skipping worktree list: /workspace/deleted-repo' + ) + warnSpy.mockRestore() + }) + it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => { gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.join(' ') === 'worktree list --porcelain -z') { + if (args.join(' ') === 'worktree list --porcelain') { return { stdout: - 'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' + - 'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0\0', + 'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' + + 'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n\n', stderr: '' } } throw new Error(`Unexpected git call: ${args.join(' ')}`) }) - translateWslOutputPathsMock.mockImplementation((output: string) => { - expect(output).not.toContain('\0') - return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') - }) + translateWslOutputPathsMock.mockImplementation((output: string) => + output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + ) const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature' resolveGitDirMock.mockImplementation(async (worktreePath: string) => worktreePath === featureWorktreePath @@ -394,7 +413,7 @@ describe('listWorktrees', () => { // Why: the detection path must not spawn a git subprocess per worktree — // the perf regression in #1131 came from `git sparse-checkout list` firing // on every poll. - expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) + expect(getGitCalls()).toEqual(['git worktree list --porcelain']) }) }) @@ -425,10 +444,18 @@ describe('addSparseWorktree', () => { it('removes the worktree and deletes the created branch when sparse setup fails', async () => { mockGitCommands({ + // Why: addWorktree probes push.autoSetupRemote after `worktree add` to + // decide whether to set it locally. Without an explicit mock the helper + // returns empty stdout and the production code skips the `--local` write, + // exercising the wrong branch. Throw with code 1 to mirror git's "key + // unset" exit, which is what worktree.ts treats as "needs to be set". + 'git config --get push.autoSetupRemote': { + error: Object.assign(new Error('key unset'), { code: 1 }) + }, 'git sparse-checkout set -- packages/web': { error: new Error('sparse setup failed') }, - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -438,7 +465,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -453,7 +480,9 @@ branch refs/heads/main const calls = getGitCalls() expect(calls).toEqual( expect.arrayContaining([ - 'git worktree add --no-checkout -b feature/test /repo-feature', + 'git worktree add --no-checkout --no-track -b feature/test /repo-feature', + 'git config --get push.autoSetupRemote', + 'git config --local push.autoSetupRemote true', 'git sparse-checkout init --cone', 'git sparse-checkout set -- packages/web', 'git worktree remove --force /repo-feature', diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index bdf8f7a7042..762ca6dd09f 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -223,6 +223,63 @@ export async function getBaseRefDefault(path: string): Promise { return getDefaultBaseRefAsync(path) } +/** + * Return { ahead, behind } for localRef vs remoteRef, or null on git failure. + * + * Why: `rev-list --left-right --count A...B` emits `\t` — + * ahead = commits on A not reachable from B; behind = commits on B not + * reachable from A. This is the merge-base-symmetric delta used by the + * stale-base dispatch guard (§3.1). Returning null on any failure (bad + * ref, corrupt repo, non-numeric output) lets callers degrade gracefully + * instead of failing dispatch on a probe error. + */ +export function getRemoteDrift( + repoPath: string, + localRef: string, + remoteRef: string +): { ahead: number; behind: number } | null { + try { + const stdout = gitExecFileSync( + ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], + { cwd: repoPath } + ) + const [aheadStr, behindStr] = stdout.trim().split(/\s+/) + const ahead = Number(aheadStr) + const behind = Number(behindStr) + if (!Number.isFinite(ahead) || !Number.isFinite(behind)) { + return null + } + return { ahead, behind } + } catch { + return null + } +} + +/** + * Up to `limit` commit subjects present on remoteRef but not localRef, in + * recency order. Returns [] on git failure. + * + * Why: powers the preamble drift section (§3.2) so a worker dispatched + * against an acknowledged-stale base can see at a glance whether the + * drift touches their task area. + */ +export function getRecentDriftSubjects( + repoPath: string, + localRef: string, + remoteRef: string, + limit: number +): string[] { + try { + const stdout = gitExecFileSync( + ['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`], + { cwd: repoPath } + ) + return stdout.split('\n').filter((s) => s.trim().length > 0) + } catch { + return [] + } +} + /** * Parse `git remote` stdout into a count of configured remotes. * diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts new file mode 100644 index 00000000000..e83af8c115f --- /dev/null +++ b/src/main/git/runner.test.ts @@ -0,0 +1,91 @@ +// Why: covers two recent classifier fixes — Retry-After honoring on 429 +// (transient detection must propagate, not silently retry on 250ms cadence) +// and stderr extraction from execFile rejections (err.message is unreliable). +import { describe, expect, it } from 'vitest' +import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner' + +describe('parseRetryAfterMs', () => { + it('returns null when no Retry-After is present', () => { + expect(parseRetryAfterMs('HTTP 429 Too Many Requests')).toBeNull() + }) + + it('parses integer seconds', () => { + expect(parseRetryAfterMs('HTTP 429\nRetry-After: 30\n')).toBe(30_000) + }) + + it('handles case-insensitive header name and surrounding whitespace', () => { + expect(parseRetryAfterMs(' retry-after: 12 \n')).toBe(12_000) + }) + + it('returns null for malformed values', () => { + expect(parseRetryAfterMs('Retry-After: not-a-date')).toBeNull() + }) +}) + +describe('isTransientGhError', () => { + it('retries 5xx errors', () => { + expect(isTransientGhError('HTTP 502 Bad Gateway')).toBe(true) + expect(isTransientGhError('http 503')).toBe(true) + }) + + it('retries network resets', () => { + expect(isTransientGhError('connect ECONNRESET 10.0.0.1:443')).toBe(true) + expect(isTransientGhError('socket hang up')).toBe(true) + }) + + it('retries 429 without Retry-After', () => { + expect(isTransientGhError('HTTP 429 Too Many Requests')).toBe(true) + }) + + it('does NOT retry 429 with Retry-After', () => { + // Why: when GitHub returns Retry-After, the server is telling us how long + // to wait. Retrying on our 250ms cadence just earns another 429 and burns + // the retry budget. + expect(isTransientGhError('HTTP 429 Too Many Requests\nRetry-After: 60\n')).toBe(false) + }) + + it("does NOT retry 4xx that aren't 429", () => { + expect(isTransientGhError('HTTP 401 Unauthorized')).toBe(false) + expect(isTransientGhError('HTTP 404 Not Found')).toBe(false) + expect(isTransientGhError('HTTP 422 Unprocessable Entity')).toBe(false) + }) +}) + +describe('extractExecError', () => { + it('reads stderr and stdout from explicit fields', () => { + const err = Object.assign(new Error('Command failed'), { + stderr: 'real stderr content', + stdout: '{"data": null}' + }) + expect(extractExecError(err)).toEqual({ + stderr: 'real stderr content', + stdout: '{"data": null}' + }) + }) + + it('decodes Buffer stderr/stdout', () => { + const err = Object.assign(new Error('Command failed'), { + stderr: Buffer.from('buf-stderr', 'utf-8'), + stdout: Buffer.from('buf-stdout', 'utf-8') + }) + expect(extractExecError(err)).toEqual({ + stderr: 'buf-stderr', + stdout: 'buf-stdout' + }) + }) + + it('falls back to err.message when stderr/stdout are absent', () => { + const err = new Error('Some message') + expect(extractExecError(err)).toEqual({ + stderr: 'Some message', + stdout: '' + }) + }) + + it('handles non-Error rejections', () => { + expect(extractExecError('plain string error')).toEqual({ + stderr: 'plain string error', + stdout: '' + }) + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 9ed285df923..1045c14980b 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -6,13 +6,7 @@ * This module detects WSL paths and routes command execution through `wsl.exe -d ` * with translated Linux paths, so every call site gets WSL support for free. */ -import { - execFile, - execFileSync, - spawn, - type ChildProcess, - type SpawnOptions -} from 'child_process' +import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process' import { promisify } from 'util' import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' @@ -70,13 +64,26 @@ function translateArgsForWsl(args: string[]): string[] { function resolveCommand( command: string, args: string[], - cwd: string | undefined + cwd: string | undefined, + wslDistroOverride?: string ): ResolvedCommand { - if (!cwd || process.platform !== 'win32') { + if (process.platform !== 'win32') { return { binary: command, args, cwd, wsl: null } } - const wsl = parseWslPath(cwd) + // Why: global gh callers (rate_limit, listAccessibleProjects) have no + // meaningful cwd to derive a WSL distro from. On WSL-only Windows setups, + // gh.exe isn't on the host PATH and the spawn fails with ENOENT. Allow + // callers to pass a distro hint so we can route through wsl.exe regardless. + // TODO(wsl-default-distro): the codebase currently has no persistent + // "default WSL distro" setting — distros are derived from individual repo + // paths. Until such a setting exists, global gh callers without an explicit + // override silently fall back to host gh.exe, which on WSL-only Windows + // installs will ENOENT. The wslDistroOverride parameter is the hook for + // wiring a future setting in without re-plumbing the runner. + const cwdWsl = cwd ? parseWslPath(cwd) : null + const wsl: WslPathInfo | null = + cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null) if (!wsl) { return { binary: command, args, cwd, wsl: null } } @@ -86,11 +93,14 @@ function resolveCommand( // inside the bash -c string. Single quotes are safe for all chars except // single quotes themselves, which we escape as '\'' (end quote, escaped // literal, reopen quote). - const escapedArgs = translatedArgs.map( - (a) => `'${a.replace(/'/g, "'\\''")}'` - ) - const escapedCwd = wsl.linuxPath.replace(/'/g, "'\\''") - const shellCmd = `cd '${escapedCwd}' && ${command} ${escapedArgs.join(' ')}` + const escapedArgs = translatedArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`) + // Why: when cwd is supplied as a WSL UNC path, prepend `cd &&` + // so the command runs in the expected directory. When the caller only + // supplied a distro override (no cwd), skip the cd entirely — the gh CLI + // doesn't need a particular cwd for global calls like `api rate_limit`. + const shellCmd = cwdWsl + ? `cd '${cwdWsl.linuxPath.replace(/'/g, "'\\''")}' && ${command} ${escapedArgs.join(' ')}` + : `${command} ${escapedArgs.join(' ')}` return { binary: 'wsl.exe', @@ -175,10 +185,7 @@ export function gitExecFileSync( * Spawn a git child process. Drop-in replacement for * `spawn('git', args, { cwd, stdio, ... })`. */ -export function gitSpawn( - args: string[], - options: SpawnOptions & { cwd: string } -): ChildProcess { +export function gitSpawn(args: string[], options: SpawnOptions & { cwd: string }): ChildProcess { const resolved = resolveCommand('git', args, options.cwd) return spawn(resolved.binary, resolved.args, { ...options, @@ -188,23 +195,276 @@ export function gitSpawn( // ─── gh CLI runners ───────────────────────────────────────────────── +// Why: non-repo-scoped gh calls (listAccessibleProjects, rate_limit, etc.) +// have no meaningful cwd. Allow it to be omitted so the one WSL-aware wrapper +// serves both repo-scoped and global callers and we stop having two spawn +// sites (the other one — a plain execFileAsync in project-view.ts — bypasses +// retry/backoff and any future quota tracker). +// Why: `wslDistro` is an explicit hint for global (cwd-less) gh callers on +// WSL-only Windows installs where gh.exe isn't on the host PATH. When set, +// resolveCommand routes the spawn through `wsl.exe -d -- gh ...` +// even without a UNC cwd to parse a distro from. Repo-scoped callers should +// keep using cwd — the distro derives from the path automatically there. +// Why: `idempotent` gates the transient-error retry. When undefined we +// auto-detect from argv (writes are detected by `-X POST/PATCH/PUT/DELETE` +// or a `query=mutation …` arg); callers can also pass an explicit override. +// A 5xx/socket reset after the request reaches GitHub but before the +// response returns is the canonical case where the server-side write +// succeeded; retrying would create a duplicate comment/issue/label addition. +// See bug-scan finding 1. +type GhExecOptions = Omit & { + cwd?: string + wslDistro?: string + idempotent?: boolean +} + +const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) +// `gh ` write subcommands. Reads (view/list/status/checks) +// are absent on purpose so the default of "retry" stays for them. +const NON_IDEMPOTENT_GH_VERBS = new Set([ + 'create', + 'edit', + 'delete', + 'close', + 'reopen', + 'merge', + 'comment', + 'review', + 'ready', + 'lock', + 'unlock', + 'pin', + 'unpin', + 'transfer', + 'develop' +]) + +function argsLookIdempotent(args: string[]): boolean { + let explicitMethodSeen = false + let hasApiBodyField = false + for (let i = 0; i < args.length; i++) { + const a = args[i] + if (a === '-X' || a === '--method') { + explicitMethodSeen = true + const next = args[i + 1] + if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) { + return false + } + } + // Single-token form `--method=POST` (gh accepts this). + if (a.startsWith('--method=')) { + explicitMethodSeen = true + const value = a.slice('--method='.length) + if (NON_IDEMPOTENT_METHODS.has(value.toUpperCase())) { + return false + } + } + // `gh api` auto-switches GET→POST when -f/-F/--field/--raw-field body + // fields are supplied without an explicit -X. Track those to classify + // such calls as non-idempotent. + if (a === '-f' || a === '-F' || a === '--field' || a === '--raw-field') { + hasApiBodyField = true + } else if ( + a.startsWith('-f=') || + a.startsWith('-F=') || + a.startsWith('--field=') || + a.startsWith('--raw-field=') + ) { + hasApiBodyField = true + } + // `gh api graphql -f query=mutation(...){ ... }` — detect mutation queries + // so writes via the GraphQL endpoint also fail fast on transient errors. + if (a.startsWith('query=')) { + const trimmed = a.slice('query='.length).trimStart().toLowerCase() + if (trimmed.startsWith('mutation')) { + return false + } + } + } + // `gh api ... -f foo=bar` with no explicit method: gh switches to POST. + // Treat as non-idempotent so a transient 5xx after the server applied + // the write doesn't retry and duplicate it. + if (args[0] === 'api' && hasApiBodyField && !explicitMethodSeen) { + return false + } + // `gh issue close`, `gh pr edit`, `gh pr merge`, etc. The first arg is the + // noun (issue/pr/repo/label/...) and the second is the verb. Defaulting + // `gh api` calls without an explicit -X to GET-equivalent (idempotent) is + // intentional: callers that POST through `gh api` set `-X POST`. + if (args.length >= 2 && args[0] !== 'api') { + if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { + return false + } + } + return true +} + +/** + * Extract stderr from an execFile rejection. + * + * Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr` + * fields populated separately from `.message`. Reading `err.message` alone is + * unreliable — it can truncate stderr or omit it entirely depending on Node + * version and maxBuffer behavior. We prefer the explicit fields and fall + * back to `.message` only when neither is present. + */ +export function extractExecError(err: unknown): { stderr: string; stdout: string } { + if (err && typeof err === 'object') { + const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } + const stderr = + typeof e.stderr === 'string' + ? e.stderr + : Buffer.isBuffer(e.stderr) + ? e.stderr.toString('utf-8') + : '' + const stdout = + typeof e.stdout === 'string' + ? e.stdout + : Buffer.isBuffer(e.stdout) + ? e.stdout.toString('utf-8') + : '' + if (stderr || stdout) { + return { stderr, stdout } + } + if (typeof e.message === 'string') { + return { stderr: e.message, stdout: '' } + } + } + return { stderr: String(err), stdout: '' } +} + +/** + * Detect a Retry-After hint in gh stderr and return the suggested delay in ms, + * or null when the response includes no Retry-After. + * + * Why: gh forwards response headers when verbose, and prints "Retry-After: + * " in error output for primary rate-limit 429s. When present, the + * caller is better served by propagating the error so the UI can surface the + * real wait time — retrying on our own 250ms cadence just earns another 429 + * and burns the retry budget. Also supports HTTP-date Retry-After values. + */ +export function parseRetryAfterMs(stderr: string): number | null { + const m = stderr.match(/retry-after:\s*([^\r\n]+)/i) + if (!m) { + return null + } + const raw = m[1].trim() + if (/^\d+$/.test(raw)) { + const seconds = Number(raw) + return Number.isFinite(seconds) ? seconds * 1000 : null + } + const ts = Date.parse(raw) + if (Number.isNaN(ts)) { + return null + } + return Math.max(0, ts - Date.now()) +} + +/** + * Classify whether a gh execFile rejection is worth retrying. + * + * Why: gh surfaces HTTP status in stderr as "HTTP 504", "HTTP 502", etc. + * Network resets and DNS hiccups also show up as stderr substrings. We retry + * those and 429 (rate-limited) — but only 429s without an explicit + * Retry-After (the caller is better off propagating so the UI can show the + * actual wait time). The primary-rate-limit 403 branch is NOT retried: those + * require the user to back off for minutes, which is not transient. + */ +export function isTransientGhError(stderr: string): boolean { + const s = stderr.toLowerCase() + if ( + s.includes('http 500') || + s.includes('http 502') || + s.includes('http 503') || + s.includes('http 504') || + s.includes('econnreset') || + s.includes('etimedout') || + s.includes('socket hang up') + ) { + return true + } + // 429 without Retry-After: retry. With Retry-After: propagate. + if (s.includes('http 429')) { + return parseRetryAfterMs(stderr) === null + } + return false +} + +// Why: total of 3 attempts (original + 2 retries) with 250ms → 1s backoff. +// These are standard "transient 5xx" values. Longer waits push past user +// patience for an interactive action; shorter waits would hammer the same +// unhealthy upstream that just failed. The array length defines retry count; +// total attempts = length + 1. +const GH_RETRY_DELAYS_MS = [250, 1000] as const + +// Why: the upstream Retry-After header is server-suggested but unbounded — +// GitHub has been observed to send tens-of-seconds values on rare incidents, +// and a malicious or misconfigured proxy could send anything. Cap the wait +// at 30s so a single transient gh call can never block the IPC main thread +// for longer than the user's patience budget for an interactive action. +const GH_RETRY_AFTER_MAX_MS = 30_000 + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + /** * Async gh CLI execution. Drop-in replacement for * `execFileAsync('gh', args, { cwd, encoding, ... })`. + * + * Retries transient 5xx / 429 (without Retry-After) / network-reset failures + * with exponential backoff. Non-transient errors (auth, 404, rate-limit 403, + * validation, 429-with-Retry-After) fail fast on the first attempt. */ export async function ghExecFileAsync( args: string[], - options: GitExecOptions + options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - const resolved = resolveCommand('gh', args, options.cwd) - const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: (options.encoding ?? 'utf-8') as BufferEncoding, - maxBuffer: options.maxBuffer, - timeout: options.timeout, - env: options.env - }) - return { stdout: stdout as string, stderr: stderr as string } + const resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) + let lastError: unknown + for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { + try { + const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + env: options.env + }) + return { stdout: stdout as string, stderr: stderr as string } + } catch (err) { + lastError = err + const { stderr } = extractExecError(err) + const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length + // Why: only retry idempotent calls. A 5xx/socket reset can arrive + // after the server already applied a POST/PATCH/PUT/DELETE; retrying + // would duplicate the write (e.g. double-post a comment, double-add + // a label). When the caller doesn't say, we auto-detect from argv — + // explicit `-X ` and GraphQL `query=mutation …` are treated + // as non-idempotent. See bug-scan finding 1. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { + // Why: when the upstream surfaced a Retry-After (e.g. on a transient + // 5xx that GitHub explicitly recommends backing off for), honor it + // instead of using our default backoff — sleeping less than the + // server suggests just earns another failure and burns our retry + // budget. Cap at GH_RETRY_AFTER_MAX_MS so a pathologically large + // hint can't block IPC for minutes; if the real wait is longer, the + // attempt will fail again and the error will propagate to the UI + // where the user can see it. + const retryAfterMs = parseRetryAfterMs(stderr) + const delayMs = + retryAfterMs !== null + ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) + : GH_RETRY_DELAYS_MS[attempt] + await sleep(delayMs) + continue + } + throw err + } + } + // Unreachable: the loop either returns or throws. Here for TS exhaustiveness. + throw lastError } // ─── Generic command runner (for rg, etc.) ────────────────────────── @@ -234,10 +494,7 @@ export function wslAwareSpawn( * are Linux-native (/home/user/repo). The rest of Orca needs Windows UNC * paths (\\wsl.localhost\Ubuntu\home\user\repo) to read files via Node fs. */ -export function translateWslOutputPaths( - output: string, - originalCwd: string -): string { +export function translateWslOutputPaths(output: string, originalCwd: string): string { const wsl = parseWslPath(originalCwd) if (!wsl) { return output @@ -245,9 +502,8 @@ export function translateWslOutputPaths( // Replace absolute Linux paths that start with / and look like filesystem // paths in structured git output (e.g. "worktree /home/user/repo/feature") - return output.replace( - /(?<=worktree )(\/.+)$/gm, - (_match, linuxPath: string) => toWindowsWslPath(linuxPath, wsl.distro) + return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) => + toWindowsWslPath(linuxPath, wsl.distro) ) } diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 26e679b812e..fb182ea47ba 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -278,6 +278,52 @@ describe('getStatus', () => { expect(result.entries[0]?.status).toBe('modified') expect(result.entries[0]?.conflictKind).toBe('added_by_us') }) + + it('passes core.quotePath=false and round-trips UTF-8 paths', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: + '1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a docs/日本語/sample.md\n' + }) + + const result = await getStatus('/repo') + + // Why: without -c core.quotePath=false git would emit + // "docs/\346\227\245\346\234\254\350\252\236/sample.md" (octal-escaped, + // wrapped in double quotes) and the parser would store that literal + // string as entry.path, breaking sidebar display + downstream blob reads. + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ], + { cwd: '/repo' } + ) + expect(result.entries).toEqual([ + { path: 'docs/日本語/sample.md', status: 'modified', area: 'unstaged' } + ]) + }) + + it('parses branch identity from porcelain v2 branch headers', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: + '# branch.oid abcdef1234567890\n# branch.head feature/prompts\n1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a src/app.ts\n' + }) + + const result = await getStatus('/repo') + + expect(result).toMatchObject({ + head: 'abcdef1234567890', + branch: 'refs/heads/feature/prompts' + }) + }) }) describe('detectConflictOperation', () => { @@ -388,4 +434,32 @@ describe('getBranchCompare', () => { expect(result.summary.errorMessage).toContain('merge base') expect(result.entries).toEqual([]) }) + + it('passes core.quotePath=false to diff --name-status and parses UTF-8 paths', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'main\n' }) + .mockResolvedValueOnce({ stdout: 'head-oid\n' }) + .mockResolvedValueOnce({ stdout: 'base-oid\n' }) + .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) + .mockResolvedValueOnce({ stdout: 'M\tdocs/日本語/sample.md\n' }) + .mockResolvedValueOnce({ stdout: '1\n' }) + + const result = await getBranchCompare('/repo', 'origin/main') + + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 5, + [ + '-c', + 'core.quotePath=false', + 'diff', + '--name-status', + '-M', + '-C', + 'merge-base-oid', + 'head-oid' + ], + expect.objectContaining({ cwd: '/repo' }) + ) + expect(result.entries).toEqual([{ path: 'docs/日本語/sample.md', status: 'modified' }]) + }) }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 4c9345fc182..77f0a81df09 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -23,13 +23,20 @@ const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 */ export async function getStatus(worktreePath: string): Promise { const entries: GitStatusEntry[] = [] + let head: string | undefined + let branch: string | undefined // Why: detectConflictOperation (4 existsSync + readFile) and git status are // independent. Running them concurrently saves one round-trip of I/O latency. const conflictPromise = detectConflictOperation(worktreePath) - const statusPromise = gitExecFileAsync(['status', '--porcelain=v2', '--untracked-files=all'], { - cwd: worktreePath - }) + // Why: -c core.quotePath=false keeps non-ASCII filenames (Japanese, emoji, + // etc.) as raw UTF-8 instead of git's default C-style octal escapes wrapped + // in double quotes. Without it, the parsed entry.path is unreadable in the + // sidebar and downstream `git show :"docs/\346..."` lookups silently miss. + const statusPromise = gitExecFileAsync( + ['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all'], + { cwd: worktreePath } + ) const conflictOperation = await conflictPromise try { @@ -42,6 +49,20 @@ export async function getStatus(worktreePath: string): Promise continue } + if (line.startsWith('# branch.oid ')) { + head = line.slice('# branch.oid '.length).trim() + continue + } + + if (line.startsWith('# branch.head ')) { + const branchHead = line.slice('# branch.head '.length).trim() + // Why: undefined (not '') for detached/empty so renderer's + // `identity.branch ?? worktree.branch` preserves the prior branch + // value when git can't report one, instead of overwriting it with ''. + branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : undefined + continue + } + if (line.startsWith('1 ') || line.startsWith('2 ')) { // Changed entries: "1 XY sub mH mI mW hH path" or "2 XY sub mH mI mW hH X\tscore\tpath\torigPath" const parts = line.split(' ') @@ -90,7 +111,7 @@ export async function getStatus(worktreePath: string): Promise // Not a git repo or git not available } - return { entries, conflictOperation } + return { entries, conflictOperation, head, branch } } function parseStatusChar(char: string): GitFileStatus { @@ -430,8 +451,10 @@ async function loadBranchChanges( mergeBase: string, headOid: string ): Promise { + // Why: see core.quotePath=false rationale in getStatus — same reason here so + // branch-diff entries render with their real UTF-8 paths. const { stdout } = await gitExecFileAsync( - ['diff', '--name-status', '-M', '-C', mergeBase, headOid], + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } ) @@ -659,6 +682,34 @@ export async function unstageFile(worktreePath: string, filePath: string): Promi await gitExecFileAsync(['restore', '--staged', '--', filePath], { cwd: worktreePath }) } +export async function commitChanges( + worktreePath: string, + message: string +): Promise<{ success: boolean; error?: string }> { + try { + await gitExecFileAsync(['commit', '-m', message], { cwd: worktreePath }) + return { success: true } + } catch (error) { + // Why: surface whichever channel carries the useful message. Pre-commit/GPG + // hook failures write to stderr; "nothing to commit, working tree clean" + // writes to stdout. Try stderr first, fall back to stdout, then error.message. + const readStringField = (field: string): string | null => { + if (typeof error === 'object' && error && field in error) { + const v = (error as Record)[field] + if (typeof v === 'string' && v.length > 0) { + return v + } + } + return null + } + const errorMessage = + readStringField('stderr') ?? + readStringField('stdout') ?? + (error instanceof Error ? error.message : 'Commit failed') + return { success: false, error: errorMessage } + } +} + /** * Discard working tree changes for a file. */ diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts new file mode 100644 index 00000000000..9f8a0ddc58e --- /dev/null +++ b/src/main/git/upstream.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { getUpstreamStatus } from './upstream' + +const missingTrackingRefError = new Error( + "fatal: ambiguous argument 'HEAD@{u}': unknown revision or path not in the working tree.\n" + + "Use '--' to separate paths from revisions, like this:\n" + + "'git [...] -- [...]'" +) + +describe('getUpstreamStatus', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('returns upstream and ahead/behind counts when tracking is configured', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'origin/main\n' }) + .mockResolvedValueOnce({ stdout: '2\t3\n' }) + + const result = await getUpstreamStatus('/repo') + + expect(result).toEqual({ + hasUpstream: true, + upstreamName: 'origin/main', + ahead: 2, + behind: 3 + }) + }) + + it('returns hasUpstream=false when upstream is missing', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: no upstream configured')) + + const result = await getUpstreamStatus('/repo') + + expect(result).toEqual({ + hasUpstream: false, + ahead: 0, + behind: 0 + }) + }) + + it('returns hasUpstream=false when the configured tracking ref is missing', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce(missingTrackingRefError) + + const result = await getUpstreamStatus('/repo') + + expect(result).toEqual({ + hasUpstream: false, + ahead: 0, + behind: 0 + }) + }) +}) diff --git a/src/main/git/upstream.ts b/src/main/git/upstream.ts new file mode 100644 index 00000000000..4b2146edcc6 --- /dev/null +++ b/src/main/git/upstream.ts @@ -0,0 +1,61 @@ +import type { GitUpstreamStatus } from '../../shared/types' +import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' +import { gitExecFileAsync } from './runner' + +export async function getUpstreamStatus(worktreePath: string): Promise { + try { + const { stdout: upstreamStdout } = await gitExecFileAsync( + ['rev-parse', '--abbrev-ref', 'HEAD@{u}'], + { + cwd: worktreePath + } + ) + const upstreamName = upstreamStdout.trim() + if (!upstreamName) { + return { hasUpstream: false, ahead: 0, behind: 0 } + } + + const { stdout: countsStdout } = await gitExecFileAsync( + ['rev-list', '--left-right', '--count', 'HEAD...@{u}'], + { + cwd: worktreePath + } + ) + + const tokens = countsStdout.trim().split(/\s+/) + if (tokens.length !== 2) { + // Why: 'rev-list --left-right --count HEAD...@{u}' must emit exactly two + // tokens; anything else (empty stdout, truncation, unexpected locale) is a + // real failure and must not be silently reported as "in sync" 0/0. + throw new Error(`Unexpected git rev-list output: ${JSON.stringify(countsStdout)}`) + } + const ahead = Number.parseInt(tokens[0]!, 10) + const behind = Number.parseInt(tokens[1]!, 10) + if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) { + throw new Error(`Unparseable git rev-list counts: ${JSON.stringify(countsStdout)}`) + } + + return { + hasUpstream: true, + upstreamName, + ahead, + behind + } + } catch (error) { + // Why: we only swallow clearly-no-upstream signals — that's an expected + // state, not a failure. Other errors (auth, corruption, "not a git + // repository", sparse-checkout) should surface to the user so they can + // act on them. The shared isNoUpstreamError helper intentionally omits + // broad phrases like "no such branch" to avoid masking real errors. + if (isNoUpstreamError(error)) { + return { + hasUpstream: false, + ahead: 0, + behind: 0 + } + } + // Why: parity with gitPush/gitPull/gitFetch — normalize before crossing + // the IPC boundary so renderers don't see execFile stderr preambles or local paths. + throw new Error(normalizeGitErrorMessage(error, 'upstream')) + } +} diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 411152d3aca..07d4e9734df 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -207,12 +207,117 @@ describe('addWorktree', () => { }) it('creates the worktree without touching the local base ref by default', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') expect(gitExecFileAsyncMock.mock.calls).toEqual([ - [['worktree', 'add', '-b', 'feature/test', '/repo-feature', 'origin/main'], { cwd: '/repo' }] + [ + ['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'], + { cwd: '/repo' } + ], + [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }], + [['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }] + ]) + }) + + it('warns but does not throw when push.autoSetupRemote config fails', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get (unset, expected) + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local set fails + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') + ).resolves.toBeUndefined() + + expect(warnSpy).toHaveBeenCalledWith( + 'addWorktree: failed to set push.autoSetupRemote for /repo-feature', + expect.any(Error) + ) + warnSpy.mockRestore() + }) + + it('warns and skips --local set when --get fails with non-unset error (e.g. corrupt config)', async () => { + // Why: exit 1 from `git config --get` means "key unset" — anything else + // is a real read failure (parse error, locked file). We must NOT fall + // through to `--local set true`, which would silently overwrite whatever + // value the user actually has. + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get fails non-unset + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') + ).resolves.toBeUndefined() + + expect(warnSpy).toHaveBeenCalledWith( + 'addWorktree: failed to set push.autoSetupRemote for /repo-feature', + expect.any(Error) + ) + // No --local set was attempted: only worktree add + the failing --get. + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [ + ['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'], + { cwd: '/repo' } + ], + [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }] + ]) + warnSpy.mockRestore() + }) + + it('preserves existing push.autoSetupRemote value (does not overwrite user-set false)', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'false\n' }) // config --get returns existing value + + await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') + + // No --local set: --get succeeded so we preserve the user's value. + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [ + ['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'], + { cwd: '/repo' } + ], + [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }] + ]) + }) + + it('treats --get success with empty stdout as "already set" (key present but blank)', async () => { + // Why: `git config --get key` exits 0 if the key has any value at any + // scope, including the unusual case of an explicitly empty string. We + // must not fall through to `--local set true` and overwrite that. + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --get succeeds with empty value + + await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [ + ['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'], + { cwd: '/repo' } + ], + [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }] + ]) + }) + + it('does not probe or write config when worktree add itself fails', async () => { + // Why: a refactor that moves the config block earlier could try to write + // push.autoSetupRemote against a worktree directory that was never + // created. Pin the current ordering invariant: config calls happen only + // after worktree add succeeds. + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('worktree add failed')) + + await expect( + addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') + ).rejects.toThrow('worktree add failed') + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [ + ['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'], + { cwd: '/repo' } + ] ]) }) @@ -225,6 +330,8 @@ describe('addWorktree', () => { .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo) .mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo) .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -233,7 +340,12 @@ describe('addWorktree', () => { [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], [['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }], [['reset', '--hard', 'origin/main'], { cwd: '/repo' }], - [['worktree', 'add', '-b', 'feature/test', '/repo-feature', 'origin/main'], { cwd: '/repo' }] + [ + ['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'], + { cwd: '/repo' } + ], + [['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }], + [['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }] ]) }) @@ -246,6 +358,8 @@ describe('addWorktree', () => { .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt) .mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt) .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -266,6 +380,8 @@ describe('addWorktree', () => { .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // update-ref .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -282,24 +398,40 @@ describe('addWorktree', () => { .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty) .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - // No reset --hard or update-ref — just merge-base, worktree list, status, worktree add - expect(gitExecFileAsyncMock.mock.calls).toHaveLength(4) + // No reset --hard or update-ref — just merge-base, worktree list, status, worktree add, config --get, config --local set + expect(gitExecFileAsyncMock.mock.calls).toHaveLength(6) expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual([ 'worktree', 'add', + '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main' ]) + expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([ + 'config', + '--get', + 'push.autoSetupRemote' + ]) + expect(gitExecFileAsyncMock.mock.calls[5]?.[0]).toEqual([ + 'config', + '--local', + 'push.autoSetupRemote', + 'true' + ]) }) it('skips updating the local branch when it has diverged', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward')) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -309,8 +441,16 @@ describe('addWorktree', () => { expect.objectContaining({ cwd: '/repo' }) ], [ - ['worktree', 'add', '-b', 'feature/test', '/repo-feature', 'origin/main'], + ['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'], expect.objectContaining({ cwd: '/repo' }) + ], + [ + ['config', '--get', 'push.autoSetupRemote'], + expect.objectContaining({ cwd: '/repo-feature' }) + ], + [ + ['config', '--local', 'push.autoSetupRemote', 'true'], + expect.objectContaining({ cwd: '/repo-feature' }) ] ]) }) @@ -323,6 +463,8 @@ describe('addWorktree', () => { .mockResolvedValueOnce({ stdout: '' }) // status --porcelain .mockResolvedValueOnce({ stdout: '' }) // reset --hard .mockResolvedValueOnce({ stdout: '' }) // worktree add + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) + .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index b77b89e33f6..f23bd54a2ad 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -8,6 +8,12 @@ type SparseWorktreeCreateError = Error & { cleanupFailed?: boolean } +function getErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined +} + function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } @@ -35,12 +41,10 @@ function looksLikeWindowsPath(pathValue: string): boolean { */ export function parseWorktreeList(output: string): GitWorktreeInfo[] { const worktrees: GitWorktreeInfo[] = [] - const blocks = output.includes('\0') - ? parseNullDelimitedWorktreeBlocks(output) - : output - .trim() - .split(/\r?\n\r?\n/) - .map((block) => block.trim().split(/\r?\n/)) + const blocks = output + .trim() + .split(/\r?\n\r?\n/) + .map((block) => block.trim().split(/\r?\n/)) for (const lines of blocks) { if (lines.length === 0) { @@ -88,11 +92,12 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { */ export async function listWorktrees(repoPath: string): Promise { try { - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { + // Why: do not pass `-z` here. `-z` requires Git ≥ 2.36; older Git rejects + // it, listWorktrees returns [], and every create flow throws "Worktree + // created but not found in listing" (issue #1453). + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { cwd: repoPath }) - // Why: WSL path translation is line-oriented, but `-z` porcelain output is - // NUL-delimited. Parse first so only complete path fields are translated. const worktrees = parseWorktreeList(stdout).map((worktree) => { const translatedPath = translateWorktreePath(worktree.path, repoPath) return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } @@ -106,7 +111,21 @@ export async function listWorktrees(repoPath: string): Promise and set it as upstream automatically — matching + // user expectations from modern git without requiring `-u`. Note that + // `--local` on a linked worktree writes to the shared common-dir config, + // so this affects the whole repo, not just this worktree. That is + // intentional and acceptable: the value is benign and idempotent, and + // every Orca-created worktree wants the same default. True per-worktree + // scope would require enabling extensions.worktreeConfig=true repo-wide, + // which is a larger change we deliberately avoid. + // + // Notes on the design: + // - push.autoSetupRemote is honored by git >= 2.37; older clients ignore + // the value, so `git push` falls back to the pre-2.37 "no upstream" + // error and the user runs `git push -u` once. + // - Failures here are warn-only: config writes are best-effort and a + // missing write degrades to the same fallback as old git. + // - The write is skipped when any value is already set (local, global, + // or system) so a deliberate user `false` is preserved. + // - Not rolled back on creation failure: addSparseWorktree's catch path + // removes the worktree but does not unset this config. That is consistent + // with the "benign and idempotent" rationale above — every Orca-created + // worktree wants this default, and a future creation will silently re-set + // it via the existing-value check anyway. + try { + // Why: `--get` (not `--local --get`) so a value set at any scope + // (local/global/system) counts as "user already chose" and we don't + // overwrite it. + let alreadySet = false + try { + await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], { + cwd: worktreePath + }) + alreadySet = true + } catch (readError) { + // Why: `git config --get` exits 1 only when the key is unset at every + // scope. Any other exit code means a real read failure (corrupt config, + // locked file, parse error) — surface that via the outer catch instead + // of silently overwriting whatever value the user actually has. + const code = (readError as { code?: unknown })?.code + if (code !== 1) { + throw readError + } + } + if (!alreadySet) { + await gitExecFileAsync(['config', '--local', 'push.autoSetupRemote', 'true'], { + cwd: worktreePath + }) + } + } catch (error) { + console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error) + } } export async function addSparseWorktree( @@ -273,28 +357,6 @@ export async function removeWorktree( } } -function parseNullDelimitedWorktreeBlocks(output: string): string[][] { - const blocks: string[][] = [] - let current: string[] = [] - - for (const token of output.split('\0')) { - if (!token) { - if (current.length > 0) { - blocks.push(current) - current = [] - } - continue - } - current.push(token) - } - - if (current.length > 0) { - blocks.push(current) - } - - return blocks -} - function translateWorktreePath(worktreePath: string, repoPath: string): string { const prefix = 'worktree ' const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath) diff --git a/src/main/github/auth-diagnose.test.ts b/src/main/github/auth-diagnose.test.ts new file mode 100644 index 00000000000..35e3775b478 --- /dev/null +++ b/src/main/github/auth-diagnose.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { parseAuthStatus } from './auth-diagnose' + +describe('parseAuthStatus', () => { + it('parses an env-shadowed login alongside a keyring login (real gh output)', () => { + const text = `github.com + ✓ Logged in to github.com account nwparker (GITHUB_TOKEN) + - Active account: true + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' + + ✓ Logged in to github.com account nwparker (keyring) + - Active account: false + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' +` + const accounts = parseAuthStatus(text) + expect(accounts).toHaveLength(2) + expect(accounts[0]).toMatchObject({ + host: 'github.com', + user: 'nwparker', + active: true, + envToken: 'GITHUB_TOKEN', + source: 'env' + }) + expect(accounts[0].scopes).toEqual(['gist', 'read:org', 'repo', 'workflow']) + expect(accounts[1]).toMatchObject({ + active: false, + envToken: null, + source: 'keyring' + }) + }) + + it('parses a single keyring login', () => { + const text = `github.com + ✓ Logged in to github.com account alice (keyring) + - Active account: true + - Token scopes: 'project', 'read:org', 'repo' +` + const accounts = parseAuthStatus(text) + expect(accounts).toHaveLength(1) + expect(accounts[0]).toMatchObject({ + user: 'alice', + active: true, + envToken: null, + source: 'keyring' + }) + expect(accounts[0].scopes).toContain('project') + }) + + it('detects GH_TOKEN env source', () => { + const text = `github.com + ✓ Logged in to github.com account bot (GH_TOKEN) + - Active account: true + - Token scopes: 'repo' +` + const [acc] = parseAuthStatus(text) + expect(acc.envToken).toBe('GH_TOKEN') + expect(acc.source).toBe('env') + }) + + it('returns empty array when nothing is logged in', () => { + expect(parseAuthStatus('You are not logged into any GitHub hosts.')).toEqual([]) + }) + + it('parses multiple hosts in one output (github.com + GHES)', () => { + const text = `github.com + ✓ Logged in to github.com account alice (keyring) + - Active account: true + - Token scopes: 'read:org', 'repo' + +ghe.acme.io + ✓ Logged in to ghe.acme.io account bob (keyring) + - Active account: true + - Token scopes: 'project', 'repo' +` + const accounts = parseAuthStatus(text) + expect(accounts.map((a) => a.host)).toEqual(['github.com', 'ghe.acme.io']) + expect(accounts.map((a) => a.user)).toEqual(['alice', 'bob']) + expect(accounts[1].scopes).toContain('project') + }) + + it('recovers host from the Logged-in line when the section header is missing', () => { + // gh prints a colon after the host on some versions; we tolerate it, + // but if the regex ever fails to match the header we still want + // accounts attributed to the host from the inline message. + const text = ` ✓ Logged in to github.acme.io account carol (keyring) + - Active account: true + - Token scopes: 'project' +` + const accounts = parseAuthStatus(text) + expect(accounts).toHaveLength(1) + expect(accounts[0].host).toBe('github.acme.io') + }) +}) diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts new file mode 100644 index 00000000000..aeaf4b9e284 --- /dev/null +++ b/src/main/github/auth-diagnose.ts @@ -0,0 +1,146 @@ +/** + * gh CLI auth diagnostics. + * + * Why: when project queries fail with "missing scope", the canned + * remediation `gh auth refresh -s project ...` silently no-ops if the user + * has `GITHUB_TOKEN` (or `GH_TOKEN`) exported in their shell — gh prefers + * env tokens over keyring credentials and refuses to refresh env-supplied + * tokens. Users follow the instructions, see no error, retry, and stay + * stuck. This probe makes that failure mode legible in the UI. + * + * Output is parsed from `gh auth status`, which prints free-form text but + * uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.). + */ +import { ghExecFileAsync } from '../git/runner' +import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github-auth-types' + +// Required scopes for ProjectV2 GraphQL access in Orca. `project` is the +// scope that gates ProjectV2 reads/writes; the others are needed for the +// surrounding repo/org queries we already run. +const REQUIRED_SCOPES = ['project', 'read:org', 'repo'] as const + +/** + * Parse `gh auth status` stderr/stdout. gh writes to stderr by default but + * has used stdout in some versions; we accept either. Format (per host): + * + * github.com + * ✓ Logged in to github.com account NAME (GITHUB_TOKEN) + * - Active account: true + * - Token scopes: 'gist', 'read:org', 'repo' + */ +export function parseAuthStatus(text: string): GhAuthAccount[] { + const accounts: GhAuthAccount[] = [] + let currentHost: string | null = null + let current: GhAuthAccount | null = null + for (const rawLine of text.split('\n')) { + const line = rawLine.replace(/\r$/, '') + // Host header: a non-indented hostname token, with an optional + // trailing colon some gh versions emit. Permits single-label hostnames + // (internal GHES like `github` or `ghe-internal`); we also recover the + // host from the `Logged in to ` line below if this header was + // missed, so a parser miss never silently drops every account. + const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*)\s*:?\s*$/i) + if (hostMatch && !/^logged\b/i.test(line)) { + currentHost = hostMatch[1] + continue + } + const loggedIn = line.match(/Logged in to (\S+) account (\S+)(?:\s+\(([^)]+)\))?/i) + if (loggedIn) { + if (current) { + accounts.push(current) + } + // Prefer the host from the `Logged in to ` line itself — it's + // always present, whereas the section header above can be skipped + // by the regex on unfamiliar gh output. + const host = loggedIn[1] || currentHost || 'github.com' + const sourceLabel = (loggedIn[3] ?? '').trim() + // gh emits "(keyring)" for stored creds and "(GITHUB_TOKEN)" / + // "(GH_TOKEN)" when an env var is shadowing the keyring. + const envToken = + sourceLabel === 'GITHUB_TOKEN' || sourceLabel === 'GH_TOKEN' ? sourceLabel : null + current = { + host, + user: loggedIn[2], + active: false, + envToken, + source: envToken ? 'env' : 'keyring', + scopes: [] + } + continue + } + if (!current) { + continue + } + const activeMatch = line.match(/Active account:\s*(true|false)/i) + if (activeMatch) { + current.active = activeMatch[1].toLowerCase() === 'true' + continue + } + const scopesMatch = line.match(/Token scopes:\s*(.+)$/i) + if (scopesMatch) { + current.scopes = scopesMatch[1] + .split(',') + .map((s) => s.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean) + } + } + if (current) { + accounts.push(current) + } + return accounts +} + +export async function diagnoseGhAuth(): Promise { + let raw = '' + let ghAvailable = true + try { + // `gh auth status` exits non-zero when no host is logged in but still + // prints the same diagnostic text we want, so capture both streams. + const { stdout, stderr } = await ghExecFileAsync(['auth', 'status']) + raw = `${stdout}\n${stderr}` + } catch (err) { + const stderr = + err && typeof err === 'object' && 'stderr' in err + ? String((err as { stderr?: unknown }).stderr ?? '') + : '' + const stdout = + err && typeof err === 'object' && 'stdout' in err + ? String((err as { stdout?: unknown }).stdout ?? '') + : '' + raw = `${stdout}\n${stderr}` + if (!raw.trim()) { + const message = err instanceof Error ? err.message : String(err) + // Most likely cause: gh CLI not installed or not on PATH. + if (/ENOENT|not found|command not found/i.test(message)) { + ghAvailable = false + } + raw = message + } + } + const accounts = parseAuthStatus(raw) + const active = accounts.find((a) => a.active) ?? accounts[0] ?? null + const envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null = process.env.GH_TOKEN + ? 'GH_TOKEN' + : process.env.GITHUB_TOKEN + ? 'GITHUB_TOKEN' + : null + const missingScopes = active + ? REQUIRED_SCOPES.filter((s) => !active.scopes.includes(s)) + : [...REQUIRED_SCOPES] + // Is there a non-env (keyring) account we could fall back to by unsetting + // the env var? Only meaningful if the active account is env-shadowed, and + // only if the keyring login is on the SAME host — otherwise unsetting the + // env var leaves the user with no credential for the host that was active. + const keyringFallback = active + ? (accounts.find((a) => a.source === 'keyring' && a.host === active.host) ?? null) + : null + return { + ghAvailable, + activeAccount: active, + accounts, + envTokenInProcess, + missingScopes, + requiredScopes: [...REQUIRED_SCOPES], + hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active) + } +} diff --git a/src/main/github/client.ts b/src/main/github/client.ts index ebf866f3248..033998d85ac 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -829,20 +829,43 @@ export async function getWorkItem( } } +export async function getWorkItemByOwnerRepo( + repoPath: string, + ownerRepo: OwnerRepo, + number: number, + type: 'issue' | 'pr' +): Promise { + await acquire() + try { + if (type === 'issue') { + return await fetchIssueWorkItem(repoPath, ownerRepo, number) + } + return await fetchPullRequestWorkItem(repoPath, ownerRepo, number) + } catch { + return null + } finally { + release() + } +} + /** * Get PR info for a given branch using gh CLI. * Returns null if gh is not installed, or no PR exists for the branch. + * + * When `linkedPRNumber` is provided and the branch lookup yields nothing, + * falls back to looking up the PR by number. This handles "create from PR" + * worktrees, whose branch is a fresh local branch (not the PR's head ref) — + * the branch-keyed lookup misses, but the user still expects the linked PR + * to surface on the worktree card. */ -export async function getPRForBranch(repoPath: string, branch: string): Promise { +export async function getPRForBranch( + repoPath: string, + branch: string, + linkedPRNumber?: number | null +): Promise { // Strip refs/heads/ prefix if present const branchName = branch.replace(/^refs\/heads\//, '') - // During a rebase the worktree is in detached HEAD and branch is empty. - // An empty --head filter causes gh to return an arbitrary PR — bail early. - if (!branchName) { - return null - } - await acquire() try { const ownerRepo = await getOwnerRepo(repoPath) @@ -861,38 +884,73 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise< headRefOid?: string } | null = null - if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'list', - '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, - '--head', - branchName, - '--state', - 'all', - '--limit', - '1', - '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' - ], - { cwd: repoPath } - ) - const list = JSON.parse(stdout) as NonNullable[] - data = list[0] ?? null - } else { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'view', - branchName, - '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' - ], - { cwd: repoPath } - ) - data = JSON.parse(stdout) + // During a rebase the worktree is in detached HEAD and branch is empty. + // An empty --head filter causes gh to return an arbitrary PR — skip the + // branch lookup and rely on the linkedPR fallback below if available. + if (branchName) { + if (ownerRepo) { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'list', + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--head', + branchName, + '--state', + 'all', + '--limit', + '1', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: repoPath } + ) + const list = JSON.parse(stdout) as NonNullable[] + data = list[0] ?? null + } else { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + branchName, + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: repoPath } + ) + data = JSON.parse(stdout) + } + } + + if (!data && typeof linkedPRNumber === 'number') { + const args = ownerRepo + ? [ + 'pr', + 'view', + String(linkedPRNumber), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ] + : [ + 'pr', + 'view', + String(linkedPRNumber), + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ] + try { + const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) + data = JSON.parse(stdout) + } catch { + // Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes + // `gh pr view ` reject. Treat that as the no-PR case so + // callers see the historical `null` semantics instead of a thrown + // error every poll cycle. + data = null + } } if (!data) { diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts new file mode 100644 index 00000000000..383fcb1d412 --- /dev/null +++ b/src/main/github/project-view.test.ts @@ -0,0 +1,144 @@ +// Why: covers the recent fixes — +// (a) network errors must NOT be misclassified as not_found ("could not +// resolve host" partially overlaps "could not resolve to a"), +// (b) repo slug validation must accept names with leading underscore +// (GitHub allows them, e.g. `_internal`), +// (c) owner slug validation must reject `.`/`_` (GitHub disallows them in +// usernames/orgs), +// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer. +import { describe, expect, it } from 'vitest' +import { + classifyProjectError, + isValidOwnerSlug, + isValidRepoSlug, + parseProjectPaste +} from './project-view' + +describe('classifyProjectError', () => { + it('classifies HTTP 404 as not_found', () => { + expect(classifyProjectError('HTTP 404 Not Found', '').type).toBe('not_found') + }) + + it('classifies "Could not resolve to a User" as not_found', () => { + expect(classifyProjectError('Could not resolve to a User with the login of foo', '').type).toBe( + 'not_found' + ) + }) + + it('classifies "could not resolve host" as network_error, NOT not_found', () => { + // Why: this was the bug — substring "could not resolve" overlaps. The + // network branch must run before not_found, and the not_found check + // must require "to a " to disambiguate. + expect(classifyProjectError('could not resolve host: api.github.com', '').type).toBe( + 'network_error' + ) + }) + + it('classifies "dial tcp" timeouts as network_error', () => { + expect(classifyProjectError('dial tcp 140.82.112.3:443: i/o timeout', '').type).toBe( + 'network_error' + ) + }) + + it('classifies rate-limit text as rate_limited', () => { + expect(classifyProjectError('API rate limit exceeded for user', '').type).toBe('rate_limited') + }) + + it('classifies missing-scope as scope_missing', () => { + expect( + classifyProjectError('your token has not been granted the required scopes', '').type + ).toBe('scope_missing') + }) + + it('classifies auth-required when gh is not signed in', () => { + expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required') + }) +}) + +describe('isValidOwnerSlug', () => { + it('accepts plain alphanumerics and hyphens', () => { + expect(isValidOwnerSlug('acme')).toBe(true) + expect(isValidOwnerSlug('acme-co')).toBe(true) + expect(isValidOwnerSlug('user1')).toBe(true) + }) + + it('rejects underscore (GitHub disallows it in usernames/orgs)', () => { + expect(isValidOwnerSlug('_acme')).toBe(false) + expect(isValidOwnerSlug('acme_co')).toBe(false) + }) + + it('rejects leading hyphen and dot', () => { + expect(isValidOwnerSlug('-acme')).toBe(false) + expect(isValidOwnerSlug('.acme')).toBe(false) + }) + + it('rejects empty and slash-containing values', () => { + expect(isValidOwnerSlug('')).toBe(false) + expect(isValidOwnerSlug('a/b')).toBe(false) + expect(isValidOwnerSlug(123)).toBe(false) + }) +}) + +describe('isValidRepoSlug', () => { + it('accepts leading underscore (GitHub allows it for repo names)', () => { + expect(isValidRepoSlug('_internal')).toBe(true) + }) + + it('accepts leading dot', () => { + expect(isValidRepoSlug('.github')).toBe(true) + }) + + it('accepts dots, dashes, underscores anywhere', () => { + expect(isValidRepoSlug('repo-name')).toBe(true) + expect(isValidRepoSlug('repo.name')).toBe(true) + expect(isValidRepoSlug('repo_name')).toBe(true) + }) + + it('rejects reserved single/double dot', () => { + expect(isValidRepoSlug('.')).toBe(false) + expect(isValidRepoSlug('..')).toBe(false) + }) + + it('rejects path separators and empty', () => { + expect(isValidRepoSlug('a/b')).toBe(false) + expect(isValidRepoSlug('')).toBe(false) + }) +}) + +describe('parseProjectPaste', () => { + it('parses owner/number shorthand', () => { + expect(parseProjectPaste('acme/42')).toEqual({ kind: 'bare', owner: 'acme', number: 42 }) + }) + + it('rejects shorthand with underscore in owner (renderer parity)', () => { + // Why: the renderer's parser uses `[A-Za-z0-9][A-Za-z0-9-]*` for owner + // (matches OWNER_SLUG_RE). Both sides must reject the same inputs. + expect(parseProjectPaste('co_op/45')).toBeNull() + }) + + it('parses org URL with view number', () => { + expect(parseProjectPaste('https://github.com/orgs/acme/projects/42/views/3')).toEqual({ + kind: 'org', + owner: 'acme', + number: 42, + viewNumber: 3 + }) + }) + + it('parses user URL', () => { + expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({ + kind: 'user', + owner: 'octocat', + number: 1 + }) + }) + + it('rejects URLs whose owner has invalid characters', () => { + expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull() + }) + + it('returns null for empty input', () => { + expect(parseProjectPaste('')).toBeNull() + expect(parseProjectPaste(' ')).toBeNull() + }) +}) diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts new file mode 100644 index 00000000000..6fa6ced6444 --- /dev/null +++ b/src/main/github/project-view.ts @@ -0,0 +1,1687 @@ +/* eslint-disable max-lines -- Why: ProjectV2 GraphQL has its own normalization +layer, retry policy (parent-field dance), paste-to-add parser, and discovery +pagination. Co-locating the read path keeps the retry/classify/normalize +contract reviewable as one surface. Lower-level plumbing (slug validation, +error classifier, runGraphql/runRest) lives in ./project-view/internals; the +slug-addressed write path lives in ./project-view/mutations. */ +import { + acquire, + release, + extractExecError, + ghExecFileAsync, + rateLimitGuard, + noteRateLimitSpend, + classifyProjectError, + driftError, + errorsIndicateParentField, + rateLimitedError, + runGraphql, + isValidOwnerSlug, + assertSlug, + assertPositiveInt, + type GhGraphqlErrorShape, + type GraphqlVars +} from './project-view/internals' +import type { + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectField, + GitHubProjectFieldValue, + GitHubProjectIteration, + GitHubProjectLabel, + GitHubProjectOwnerType, + GitHubProjectRow, + GitHubProjectRowItemType, + GitHubProjectSingleSelectOption, + GitHubProjectSort, + GitHubProjectSummary, + GitHubProjectTable, + GitHubProjectUser, + GitHubProjectView, + GitHubProjectViewError, + GitHubProjectViewLayout, + GitHubProjectViewSummary, + ListAccessibleProjectsResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ResolveProjectRefArgs, + ResolveProjectRefResult +} from '../../shared/github-project-types' + +// Re-export the public API so existing call sites (`./project-view`) keep +// working unchanged. The split is internal-only. +export { + isValidOwnerSlug, + isValidRepoSlug, + isValidSlug, + classifyProjectError +} from './project-view/internals' +export { + updateProjectItemFieldValue, + clearProjectItemFieldValue, + updateIssueBySlug, + updatePullRequestBySlug, + addIssueCommentBySlug, + updateIssueCommentBySlug, + deleteIssueCommentBySlug, + listLabelsBySlug, + listAssignableUsersBySlug, + listIssueTypesBySlug, + updateIssueTypeBySlug, + getWorkItemDetailsBySlug +} from './project-view/mutations' + +// ─── Constants ───────────────────────────────────────────────────────── + +// Why: these defaults were deliberately shrunk from 50/50/100 to cut quota +// spend in the most expensive gh call reachable from TaskPage. Discovery +// walks viewer projects, then up to `DISCOVERY_MAX_ORGS` orgs × a nested +// `projectsV2(first:N)` query. The org loop dominates the cost and is the +// path that produced the user-visible HTTP 504 when one org was slow. Users +// with projects outside this window can still paste a URL to reach them — +// no functional loss. Prior values: MAX_ORGS=50, ORG_PAGE_SIZE=30, +// PROJECTS_PER_OWNER=100, nested projectsV2 first=50. +const ITEM_PAGE_SIZE = 100 +const MAX_ITEMS = 500 +const VIEWS_PAGE_SIZE = 20 +const FIELDS_PAGE_SIZE = 50 +const DISCOVERY_PROJECTS_PER_OWNER = 40 +const DISCOVERY_MAX_ORGS = 20 +const DISCOVERY_ORG_PAGE_SIZE = 20 +const DISCOVERY_PROJECTS_PER_ORG = 20 +const FIELD_VALUES_PAGE_SIZE = 100 + +// ─── Module-scope caches (reset on HMR — intentional) ────────────────── + +// Why: HMR reloading should re-probe capability. Both caches live as plain +// module locals so a dev-time code swap naturally re-runs capability probes +// instead of carrying a stale "unsupported" flag into fresh code. +const ownerTypeCache = new Map() +// Why: keyed by `${owner}\0${ownerType}` rather than a process-global flag so +// a single owner's capability gap (e.g. token without scope on org A) doesn't +// poison every subsequent fetch for unrelated owners that DO support +// Issue.parent. See bug-scan finding 2. +const parentFieldRetriedByOwner = new Set() +const parentFieldWarningLoggedByOwner = new Set() +// Why: concurrent fetchAllItems calls for the same owner all observe the +// same "not-yet-retried" state, each issuing a duplicate first-page probe +// and racing to set the flag. Use an in-flight promise per owner so only +// one caller drives the probe; siblings await the same result. +const parentFieldProbeInFlight = new Map>() + +function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType): string { + return `${owner}\u0000${ownerType}` +} + +/** @internal — test-only */ +export function _resetProjectViewModuleState(): void { + ownerTypeCache.clear() + parentFieldRetriedByOwner.clear() + parentFieldWarningLoggedByOwner.clear() + parentFieldProbeInFlight.clear() +} + +// ─── Normalizers ─────────────────────────────────────────────────────── + +type RawProjectV2Field = { + __typename?: string + id?: string + name?: string + dataType?: string + options?: { id?: string; name?: string; color?: string }[] + configuration?: { + iterations?: { id?: string; title?: string; startDate?: string; duration?: number }[] + completedIterations?: { + id?: string + title?: string + startDate?: string + duration?: number + }[] + } +} + +export function normalizeField( + raw: RawProjectV2Field | null | undefined +): GitHubProjectField | null { + if (!raw || typeof raw.id !== 'string' || typeof raw.name !== 'string') { + return null + } + const dataType = raw.dataType ?? raw.__typename ?? '' + if (raw.__typename === 'ProjectV2SingleSelectField' || dataType === 'SINGLE_SELECT') { + const options: GitHubProjectSingleSelectOption[] = (raw.options ?? []) + .map((o) => + typeof o.id === 'string' && typeof o.name === 'string' + ? { id: o.id, name: o.name, color: o.color ?? '' } + : null + ) + .filter((o): o is GitHubProjectSingleSelectOption => o !== null) + return { kind: 'single-select', id: raw.id, name: raw.name, dataType: 'SINGLE_SELECT', options } + } + if (raw.__typename === 'ProjectV2IterationField' || dataType === 'ITERATION') { + const cfg = raw.configuration ?? {} + const iterations: GitHubProjectIteration[] = [] + for (const it of cfg.completedIterations ?? []) { + if (typeof it.id === 'string' && typeof it.title === 'string') { + iterations.push({ + id: it.id, + title: it.title, + startDate: it.startDate ?? '', + duration: typeof it.duration === 'number' ? it.duration : 0, + completed: true + }) + } + } + for (const it of cfg.iterations ?? []) { + if (typeof it.id === 'string' && typeof it.title === 'string') { + iterations.push({ + id: it.id, + title: it.title, + startDate: it.startDate ?? '', + duration: typeof it.duration === 'number' ? it.duration : 0, + completed: false + }) + } + } + return { kind: 'iteration', id: raw.id, name: raw.name, dataType: 'ITERATION', iterations } + } + return { kind: 'field', id: raw.id, name: raw.name, dataType } +} + +type RawUser = { + login?: string + name?: string | null + avatarUrl?: string | null +} + +function normalizeUser(raw: RawUser | null | undefined): GitHubProjectUser | null { + if (!raw || typeof raw.login !== 'string') { + return null + } + return { + login: raw.login, + name: raw.name ?? null, + avatarUrl: raw.avatarUrl ?? null + } +} + +type RawLabel = { name?: string; color?: string } + +function normalizeLabel(raw: RawLabel | null | undefined): GitHubProjectLabel | null { + if (!raw || typeof raw.name !== 'string') { + return null + } + return { name: raw.name, color: raw.color ?? '' } +} + +type RawFieldValue = { + __typename?: string + field?: RawProjectV2Field + name?: string + color?: string + optionId?: string + title?: string + startDate?: string + duration?: number + iterationId?: string + text?: string + number?: number + date?: string + labels?: { nodes?: RawLabel[] } + users?: { nodes?: RawUser[] } +} + +export function normalizeFieldValue( + raw: RawFieldValue | null | undefined +): GitHubProjectFieldValue | null { + if (!raw || !raw.field || typeof raw.field.id !== 'string') { + return null + } + const fieldId = raw.field.id + switch (raw.__typename) { + case 'ProjectV2ItemFieldSingleSelectValue': + if (typeof raw.optionId !== 'string') { + return null + } + return { + kind: 'single-select', + fieldId, + optionId: raw.optionId, + name: raw.name ?? '', + color: raw.color ?? '' + } + case 'ProjectV2ItemFieldIterationValue': + if (typeof raw.iterationId !== 'string') { + return null + } + return { + kind: 'iteration', + fieldId, + iterationId: raw.iterationId, + title: raw.title ?? '', + startDate: raw.startDate ?? '', + duration: typeof raw.duration === 'number' ? raw.duration : 0 + } + case 'ProjectV2ItemFieldTextValue': + return { kind: 'text', fieldId, text: raw.text ?? '' } + case 'ProjectV2ItemFieldNumberValue': + if (typeof raw.number !== 'number') { + return null + } + return { kind: 'number', fieldId, number: raw.number } + case 'ProjectV2ItemFieldDateValue': + return { kind: 'date', fieldId, date: raw.date ?? '' } + case 'ProjectV2ItemFieldLabelValue': { + const labels = (raw.labels?.nodes ?? []) + .map(normalizeLabel) + .filter((l): l is GitHubProjectLabel => l !== null) + return { kind: 'labels', fieldId, labels } + } + case 'ProjectV2ItemFieldUserValue': { + const users = (raw.users?.nodes ?? []) + .map(normalizeUser) + .filter((u): u is GitHubProjectUser => u !== null) + return { kind: 'users', fieldId, users } + } + default: + // Unknown __typename → forward-compat: drop silently, do not throw, + // do not classify as drift (see design §Error Handling). + return null + } +} + +type RawContent = { + __typename?: string + id?: string + number?: number + title?: string + body?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + repository?: { nameWithOwner?: string } + assignees?: { nodes?: RawUser[] } + labels?: { nodes?: RawLabel[] } + parent?: { number?: number; title?: string; url?: string } | null + issueType?: { + id?: string + name?: string + color?: string | null + description?: string | null + } | null +} + +type RawItem = { + id?: string + type?: string + updatedAt?: string + content?: RawContent | null + fieldValues?: { + nodes?: RawFieldValue[] + pageInfo?: { hasNextPage?: boolean } + } +} + +type NormalizedItemOutcome = + | { ok: true; row: GitHubProjectRow } + | { ok: false; drift: GitHubProjectViewError } + +function mapItemType(raw: string | undefined, hasContent: boolean): GitHubProjectRowItemType { + if (raw === 'ISSUE') { + return 'ISSUE' + } + if (raw === 'PULL_REQUEST') { + return 'PULL_REQUEST' + } + if (raw === 'DRAFT_ISSUE') { + return 'DRAFT_ISSUE' + } + if (raw === 'REDACTED' || !hasContent) { + return 'REDACTED' + } + // Unknown item type with content — treat as redacted rather than dropping. + return 'REDACTED' +} + +export function normalizeItem(raw: RawItem, position: number): NormalizedItemOutcome { + if (!raw || typeof raw.id !== 'string') { + return { + ok: false, + drift: driftError('item missing id', { path: ['items', 'nodes', position, 'id'] }) + } + } + if (raw.fieldValues?.pageInfo?.hasNextPage === true) { + return { + ok: false, + drift: driftError('item field values exceeded single page', { + path: ['items', 'nodes', position, 'fieldValues', 'pageInfo', 'hasNextPage'] + }) + } + } + const itemType = mapItemType(raw.type, raw.content !== null && raw.content !== undefined) + const content = raw.content ?? null + const assignees = (content?.assignees?.nodes ?? []) + .map(normalizeUser) + .filter((u): u is GitHubProjectUser => u !== null) + const labels = (content?.labels?.nodes ?? []) + .map(normalizeLabel) + .filter((l): l is GitHubProjectLabel => l !== null) + const parentIssue = + content?.parent && + typeof content.parent.number === 'number' && + typeof content.parent.title === 'string' && + typeof content.parent.url === 'string' + ? { number: content.parent.number, title: content.parent.title, url: content.parent.url } + : null + const issueType = + content?.issueType && + typeof content.issueType.id === 'string' && + typeof content.issueType.name === 'string' + ? { + id: content.issueType.id, + name: content.issueType.name, + color: typeof content.issueType.color === 'string' ? content.issueType.color : null, + description: + typeof content.issueType.description === 'string' ? content.issueType.description : null + } + : null + const fieldValuesByFieldId: Record = {} + for (const fv of raw.fieldValues?.nodes ?? []) { + const normalized = normalizeFieldValue(fv) + if (normalized) { + fieldValuesByFieldId[normalized.fieldId] = normalized + } + } + const title = + itemType === 'REDACTED' + ? 'Restricted item' + : typeof content?.title === 'string' + ? content.title + : '' + const row: GitHubProjectRow = { + id: raw.id, + itemType, + content: { + number: typeof content?.number === 'number' ? content.number : null, + title, + body: typeof content?.body === 'string' ? content.body : null, + url: typeof content?.url === 'string' ? content.url : null, + state: typeof content?.state === 'string' ? content.state : null, + stateReason: typeof content?.stateReason === 'string' ? content.stateReason : null, + isDraft: typeof content?.isDraft === 'boolean' ? content.isDraft : null, + repository: + typeof content?.repository?.nameWithOwner === 'string' + ? content.repository.nameWithOwner + : null, + assignees, + labels, + parentIssue, + issueType + }, + fieldValuesByFieldId, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '', + position + } + return { ok: true, row } +} + +// ─── GraphQL query fragments ─────────────────────────────────────────── + +const FIELD_CONFIG_FRAGMENT = ` +fragment FieldConfig on ProjectV2FieldConfiguration { + __typename + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { + id + name + dataType + options { id name color } + } + ... on ProjectV2IterationField { + id + name + dataType + configuration { + iterations { id title startDate duration } + completedIterations { id title startDate duration } + } + } +} +` + +function itemContentSelection(includeParent: boolean): string { + const parentFrag = includeParent ? 'parent { number title url }' : '' + return ` + __typename + ... on Issue { + id + number + title + url + state + stateReason + repository { nameWithOwner } + assignees(first:5) { nodes { login name avatarUrl } } + labels(first:10) { nodes { name color } } + issueType { id name color description } + ${parentFrag} + } + ... on PullRequest { + id + number + title + url + state + isDraft + repository { nameWithOwner } + assignees(first:5) { nodes { login name avatarUrl } } + labels(first:10) { nodes { name color } } + } + ... on DraftIssue { id title body } + ` +} + +const FIELD_VALUES_SELECTION = ` + fieldValues(first:${FIELD_VALUES_PAGE_SIZE}) { + pageInfo { hasNextPage } + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { field { ...FieldConfig } name color optionId } + ... on ProjectV2ItemFieldIterationValue { field { ...FieldConfig } title startDate duration iterationId } + ... on ProjectV2ItemFieldTextValue { field { ...FieldConfig } text } + ... on ProjectV2ItemFieldNumberValue { field { ...FieldConfig } number } + ... on ProjectV2ItemFieldDateValue { field { ...FieldConfig } date } + ... on ProjectV2ItemFieldLabelValue { field { ...FieldConfig } labels(first:10) { nodes { name color } } } + ... on ProjectV2ItemFieldUserValue { field { ...FieldConfig } users(first:5) { nodes { login name avatarUrl } } } + } + } +` + +// ─── Project config fetch (views + fields, paginated) ────────────────── + +type RawProjectConfig = { + id?: string + title?: string + url?: string + views?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectView | null)[] + } +} + +type RawProjectView = { + id?: string + number?: number + name?: string + layout?: string + filter?: string | null + fields?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectV2Field | null)[] + } + groupByFields?: { nodes?: (RawProjectV2Field | null)[] } + sortByFields?: { + nodes?: ({ direction?: string; field?: RawProjectV2Field | null } | null)[] + } +} + +function ownerQueryRoot(ownerType: GitHubProjectOwnerType): string { + return ownerType === 'organization' ? 'organization' : 'user' +} + +async function fetchProjectViewsPage(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + after: string | null +}): Promise< + | { + ok: true + project: { id: string; title: string; url: string } + views: RawProjectView[] + hasNextPage: boolean + endCursor: string | null + } + | { ok: false; error: GitHubProjectViewError } +> { + const root = ownerQueryRoot(args.ownerType) + const afterArg = args.after ? `, after: $after` : '' + const afterVar = args.after ? `$after:String!, ` : '' + const query = ` + query(${afterVar}$owner:String!, $num:Int!) { + ${root}(login:$owner) { + projectV2(number:$num) { + id title url + views(first:${VIEWS_PAGE_SIZE}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + id number name layout filter + fields(first:${FIELDS_PAGE_SIZE}) { + pageInfo { hasNextPage endCursor } + nodes { ...FieldConfig } + } + groupByFields(first:10) { nodes { ...FieldConfig } } + sortByFields(first:10) { + nodes { direction field { ...FieldConfig } } + } + } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const vars: GraphqlVars = { owner: args.owner, num: args.projectNumber } + if (args.after) { + vars.after = args.after + } + const res = await runGraphql>( + query, + vars + ) + if (!res.ok) { + return res + } + const top = res.data[root] + const project = top?.projectV2 ?? null + if (!project || typeof project.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + const pageInfo = project.views?.pageInfo + const views = (project.views?.nodes ?? []).filter((v): v is RawProjectView => v !== null) + return { + ok: true, + project: { id: project.id, title: project.title ?? '', url: project.url ?? '' }, + views, + hasNextPage: pageInfo?.hasNextPage === true, + endCursor: pageInfo?.endCursor ?? null + } +} + +async function fetchViewFieldsContinuation( + viewId: string, + after: string +): Promise< + { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } +> { + // Why: address the view directly via `node(id:)` instead of re-fetching the + // whole project + walking views every page. Previous shape paid an + // unnecessary `${VIEWS_PAGE_SIZE}` views fan-out per field-continuation + // page; the new shape is one round-trip per field page, which is the + // minimum possible cost. Field-paged views are rare (>50 fields), so this + // only matters for the few projects that hit it — but when they do, the + // savings compound across pagination loops. + const query = ` + query($after:String!, $viewId:ID!) { + node(id:$viewId) { + ... on ProjectV2View { + id + fields(first:${FIELDS_PAGE_SIZE}, after:$after) { + pageInfo { hasNextPage endCursor } + nodes { ...FieldConfig } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const collected: RawProjectV2Field[] = [] + let cursor: string | null = after + while (cursor !== null) { + const res = await runGraphql<{ + node?: { + id?: string + fields?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectV2Field | null)[] + } + } | null + }>(query, { viewId, after: cursor }) + if (!res.ok) { + return res + } + const view = res.data.node ?? null + if (!view) { + return { ok: false, error: driftError('view disappeared during field pagination') } + } + const nodes = (view.fields?.nodes ?? []).filter((f): f is RawProjectV2Field => f !== null) + collected.push(...nodes) + const pi = view.fields?.pageInfo + cursor = pi?.hasNextPage === true && typeof pi.endCursor === 'string' ? pi.endCursor : null + } + return { ok: true, fields: collected } +} + +function finalizeView( + raw: RawProjectView, + extraFields: RawProjectV2Field[] +): { ok: true; view: GitHubProjectView } | { ok: false; drift: GitHubProjectViewError } { + if (typeof raw.id !== 'string' || typeof raw.layout !== 'string') { + return { ok: false, drift: driftError('view missing id or layout') } + } + const layout = raw.layout as GitHubProjectViewLayout + const fields: GitHubProjectField[] = [] + const all = [...(raw.fields?.nodes ?? []), ...extraFields.map((f) => f as RawProjectV2Field)] + for (const f of all) { + const n = normalizeField(f) + if (n) { + fields.push(n) + } + } + const groupByFields: GitHubProjectField[] = [] + for (const f of raw.groupByFields?.nodes ?? []) { + const n = normalizeField(f) + if (n) { + groupByFields.push(n) + } + } + const sortByFields: GitHubProjectSort[] = [] + for (const s of raw.sortByFields?.nodes ?? []) { + if (!s || (s.direction !== 'ASC' && s.direction !== 'DESC')) { + continue + } + const n = normalizeField(s.field) + if (n) { + sortByFields.push({ direction: s.direction, field: n }) + } + } + return { + ok: true, + view: { + id: raw.id, + number: typeof raw.number === 'number' ? raw.number : 0, + name: typeof raw.name === 'string' ? raw.name : '', + layout, + // Why: `ProjectV2View.filter` is nullable — normalize to ''. + filter: typeof raw.filter === 'string' ? raw.filter : '', + fields, + groupByFields, + sortByFields + } + } +} + +// ─── View selection ─────────────────────────────────────────────────── + +function matchesSelector( + raw: RawProjectView, + sel: { viewId?: string; viewNumber?: number; viewName?: string } +): 'none' | 'id' | 'number' | 'name' | 'default' { + if (sel.viewId && raw.id === sel.viewId) { + return 'id' + } + if (sel.viewNumber !== undefined && raw.number === sel.viewNumber) { + return 'number' + } + if (sel.viewName && raw.name === sel.viewName) { + return 'name' + } + if ( + sel.viewId === undefined && + sel.viewNumber === undefined && + sel.viewName === undefined && + raw.layout === 'TABLE_LAYOUT' + ) { + return 'default' + } + return 'none' +} + +// ─── Items fetch (paginated) ────────────────────────────────────────── + +type RawItemsPage = { + totalCount?: number + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawItem | null)[] +} + +// Why: runGraphql returns the classified error but not the raw GraphQL +// errors; for the parent-field retry decision we need those. This variant +// returns the raw envelope so callers can re-inspect. +async function fetchItemsPageWithRaw(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string + first: number + after: string | null + includeParent: boolean +}): Promise< + | { ok: true; page: RawItemsPage } + | { + ok: false + error: GitHubProjectViewError + rawErrors: GhGraphqlErrorShape[] + stderr: string + } +> { + const root = ownerQueryRoot(args.ownerType) + const afterArg = args.after ? `, after: $after` : '' + const afterVar = args.after ? `$after:String!, ` : '' + const query = ` + query(${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!) { + ${root}(login:$owner) { + projectV2(number:$num) { + items(first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + id + type + updatedAt + content { ${itemContentSelection(args.includeParent)} } + ${FIELD_VALUES_SELECTION} + } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const argsArr: string[] = ['api', 'graphql', '-f', `query=${query}`] + argsArr.push('-f', `owner=${args.owner}`) + argsArr.push('-F', `num=${args.projectNumber}`) + argsArr.push('-f', `q=${args.query}`) + argsArr.push('-F', `first=${args.first}`) + if (args.after) { + argsArr.push('-f', `after=${args.after}`) + } + + const guard = rateLimitGuard('graphql') + if (guard.blocked) { + return { + ok: false, + error: rateLimitedError(guard), + rawErrors: [], + stderr: '' + } + } + await acquire() + noteRateLimitSpend('graphql') + try { + let stdout = '' + let stderr = '' + let execFailed = false + try { + const r = await ghExecFileAsync(argsArr, { encoding: 'utf-8' }) + stdout = r.stdout + stderr = r.stderr + } catch (err) { + const extracted = extractExecError(err) + stderr = extracted.stderr + stdout = extracted.stdout + execFailed = true + } + let parsed: { data?: Record; errors?: GhGraphqlErrorShape[] } = {} + try { + parsed = JSON.parse(stdout) + } catch { + // Why: when gh exits non-zero with no parseable JSON on stdout (network, + // auth, rate-limit, missing scope), classify against stderr so callers + // see the real cause instead of a synthesized drift/not-found. + if (execFailed) { + return { + ok: false, + error: classifyProjectError(stderr, stdout), + rawErrors: [], + stderr + } + } + return { + ok: false, + error: driftError('failed to parse items response'), + rawErrors: [], + stderr + } + } + // Why: gh exec rejected but stdout still had a parseable error envelope — + // fall through to the parsed.errors branch below. If parsed has neither + // data nor errors, surface the stderr classification rather than not_found. + if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { + return { + ok: false, + error: classifyProjectError(stderr, stdout), + rawErrors: [], + stderr + } + } + if (parsed.errors && parsed.errors.length > 0) { + return { + ok: false, + error: classifyProjectError(stderr, stdout), + rawErrors: parsed.errors, + stderr + } + } + const top = parsed.data?.[root] as { projectV2?: { items?: RawItemsPage } | null } | undefined + const page = top?.projectV2?.items + if (!page) { + return { + ok: false, + error: { type: 'not_found', message: 'Project or view not found.' }, + rawErrors: [], + stderr + } + } + return { ok: true, page } + } finally { + release() + } +} + +async function fetchAllItems(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string +}): Promise< + | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } + | { ok: false; error: GitHubProjectViewError; totalCount?: number } +> { + // Why: caches keyed by (owner, ownerType) so a single owner's lack of + // Issue.parent capability (e.g. token without scope on org A) doesn't + // poison fetches for unrelated owners. See bug-scan finding 2. + const scopeKey = ownerScopeKey(args.owner, args.ownerType) + // Why: if another caller for the SAME owner is currently probing whether + // Issue.parent is supported, await its decision so we don't fire a + // duplicate with-parent probe. We must re-read the retried flag AFTER + // awaiting because the probe may have flipped it. + const inFlight = parentFieldProbeInFlight.get(scopeKey) + if (inFlight) { + await inFlight.catch(() => {}) + } + let includeParent = !parentFieldRetriedByOwner.has(scopeKey) + let parentFieldDropped = !includeParent + // First page — single-flight the with-parent attempt PER OWNER so + // concurrent callers for the same owner observe one probe result instead + // of each issuing their own. We must assign the in-flight promise + // synchronously (no await between the get() check and the set()) so + // concurrent callers race on the same promise rather than each creating + // a duplicate probe. + let first: Awaited> + let probePromise: Promise>> | null = null + if (includeParent && !parentFieldProbeInFlight.has(scopeKey)) { + let resolveProbe: () => void = () => {} + const probe = new Promise((resolve) => { + resolveProbe = resolve + }) + parentFieldProbeInFlight.set(scopeKey, probe) + probePromise = (async () => { + try { + const result = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: null, + includeParent: true + }) + // Why: flip parentFieldRetriedByOwner BEFORE resolveProbe()/clearing + // parentFieldProbeInFlight so siblings that awoke on `inFlight.catch()` + // observe the updated flag. Doing this in the outer block (after + // `await probePromise`) leaves a gap where siblings see + // parentFieldProbeInFlight=null and parentFieldRetriedByOwner without + // the scopeKey, then issue duplicate with-parent probes. + if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { + parentFieldRetriedByOwner.add(scopeKey) + } + return result + } finally { + resolveProbe() + parentFieldProbeInFlight.delete(scopeKey) + } + })() + first = await probePromise + } else { + first = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: null, + includeParent + }) + } + if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { + // Retry the whole table without parent. Mark this owner as retried so + // subsequent fetches against the same owner skip the probe — but other + // owners are unaffected. + parentFieldRetriedByOwner.add(scopeKey) + includeParent = false + parentFieldDropped = true + if (!parentFieldWarningLoggedByOwner.has(scopeKey)) { + console.warn( + `[project-view] Issue.parent is not available for ${args.owner} on this token — retrying without the parent selection.` + ) + parentFieldWarningLoggedByOwner.add(scopeKey) + } + first = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: null, + includeParent: false + }) + } + if (!first.ok) { + return { ok: false, error: first.error } + } + + // Drift guards + if (first.page.totalCount === undefined || first.page.totalCount === null) { + return { ok: false, error: driftError('items.totalCount missing') } + } + const totalCount = first.page.totalCount + if (first.page.pageInfo?.hasNextPage === undefined) { + return { ok: false, error: driftError('items.pageInfo.hasNextPage missing'), totalCount } + } + if (!Array.isArray(first.page.nodes)) { + return { ok: false, error: driftError('items.nodes missing'), totalCount } + } + + // Size cap + if (totalCount > MAX_ITEMS) { + return { + ok: false, + error: { type: 'too_large', message: `View has ${totalCount} items.` }, + totalCount + } + } + + const rows: GitHubProjectRow[] = [] + let position = 0 + const appendNodes = (nodes: (RawItem | null)[]): GitHubProjectViewError | null => { + for (const n of nodes) { + if (!n) { + continue + } + const norm = normalizeItem(n, position) + if (!norm.ok) { + return norm.drift + } + rows.push(norm.row) + position++ + } + return null + } + const e1 = appendNodes(first.page.nodes) + if (e1) { + return { ok: false, error: e1, totalCount } + } + + // Paginate + let hasNext = first.page.pageInfo.hasNextPage === true + let cursor: string | null | undefined = first.page.pageInfo.endCursor + if (hasNext && typeof cursor !== 'string') { + return { + ok: false, + error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), + totalCount + } + } + while (hasNext) { + const next = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: cursor as string, + includeParent + }) + if (!next.ok) { + return { ok: false, error: next.error, totalCount } + } + if (!Array.isArray(next.page.nodes)) { + return { ok: false, error: driftError('items.nodes missing on follow page'), totalCount } + } + if (next.page.pageInfo?.hasNextPage === undefined) { + return { + ok: false, + error: driftError('items.pageInfo.hasNextPage missing on follow page'), + totalCount + } + } + const e2 = appendNodes(next.page.nodes) + if (e2) { + return { ok: false, error: e2, totalCount } + } + hasNext = next.page.pageInfo.hasNextPage === true + cursor = next.page.pageInfo.endCursor + if (hasNext && typeof cursor !== 'string') { + return { + ok: false, + error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), + totalCount + } + } + } + return { ok: true, rows, totalCount, parentFieldDropped } +} + +// ─── Cheap count-only query (for unsupported_layout) ────────────────── + +async function fetchItemsCountOnly(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string +}): Promise { + const root = ownerQueryRoot(args.ownerType) + const query = ` + query($owner:String!, $num:Int!, $q:String!) { + ${root}(login:$owner) { + projectV2(number:$num) { + items(first:1, query:$q) { totalCount } + } + } + } + ` + const res = await runGraphql< + Record + >(query, { owner: args.owner, num: args.projectNumber, q: args.query }) + if (!res.ok) { + return null + } + const count = res.data[root]?.projectV2?.items?.totalCount + return typeof count === 'number' ? count : null +} + +// ─── Public: getProjectViewTable ────────────────────────────────────── + +export async function getProjectViewTable( + args: GetProjectViewTableArgs +): Promise { + const ownerCheck = assertSlug(args.owner, 'owner') + if (!ownerCheck.ok) { + return { ok: false, error: ownerCheck.error } + } + const numCheck = assertPositiveInt(args.projectNumber, 'projectNumber') + if (!numCheck.ok) { + return { ok: false, error: numCheck.error } + } + if (args.ownerType !== 'organization' && args.ownerType !== 'user') { + return { + ok: false, + error: { type: 'validation_error', message: 'Invalid ownerType.' } + } + } + + // Paginate views until a match is found. + let cursor: string | null = null + let project: { id: string; title: string; url: string } | null = null + let selectedRaw: RawProjectView | null = null + let matchStrength: 'id' | 'number' | 'name' | 'default' | null = null + const viewsSeen: RawProjectView[] = [] + while (true) { + const page = await fetchProjectViewsPage({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + after: cursor + }) + if (!page.ok) { + return { ok: false, error: page.error } + } + project = page.project + for (const v of page.views) { + viewsSeen.push(v) + const m = matchesSelector(v, { + viewId: args.viewId, + viewNumber: args.viewNumber, + viewName: args.viewName + }) + if (m === 'none') { + continue + } + // Precedence: id > number > name > default. + const rank: Record = { id: 4, number: 3, name: 2, default: 1 } + const currentRank = matchStrength ? rank[matchStrength] : 0 + if (!selectedRaw || rank[m] > currentRank) { + selectedRaw = v + matchStrength = m + } + } + // Why: stop as soon as we have ANY match — including 'default' (first + // table view). Continuing to walk views pages for a default selector + // costs one extra GraphQL call per page with no upside: the default + // contract is "first table view we see", and view layouts don't change + // ordering between pages such that a later view would outrank the + // first table layout. Previously we kept walking on default match + // because the precedence comment hinted at re-ranking, but no real + // selector promotes a 'default' to a stronger match within the same + // selector input — those ranks only matter when the caller supplied + // a selector. Bail early on any non-null selectedRaw. + if (selectedRaw) { + break + } + if (!page.hasNextPage) { + break + } + cursor = page.endCursor + if (typeof cursor !== 'string') { + break + } + } + if (!project) { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + if (!selectedRaw) { + return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } } + } + + // Paginate view fields if necessary. + let extraFields: RawProjectV2Field[] = [] + const fieldsPi = selectedRaw.fields?.pageInfo + if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) { + const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor) + if (!cont.ok) { + return { ok: false, error: cont.error } + } + extraFields = cont.fields + } + + const finalized = finalizeView(selectedRaw, extraFields) + if (!finalized.ok) { + return { ok: false, error: finalized.drift } + } + const selectedView = finalized.view + + // Why: an explicit empty-string override means "no filter"; treat undefined + // as "use the view's filter as-is". The override is ephemeral — never + // persisted to GitHub — so users can clear the search without mutating the + // view's stored filter. + const effectiveQuery = + typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter + + // Unsupported layout: return without paginating items; attempt a cheap + // count-only query best-effort. + if (selectedView.layout !== 'TABLE_LAYOUT') { + const count = await fetchItemsCountOnly({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: effectiveQuery + }) + return { + ok: false, + error: { + type: 'unsupported_layout', + message: `Orca only renders table views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.` + }, + ...(typeof count === 'number' ? { totalCount: count } : {}) + } + } + + // Fetch items. + const items = await fetchAllItems({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: effectiveQuery + }) + if (!items.ok) { + return { + ok: false, + error: items.error, + ...(typeof items.totalCount === 'number' ? { totalCount: items.totalCount } : {}) + } + } + + const table: GitHubProjectTable = { + project: { + id: project.id, + owner: args.owner, + ownerType: args.ownerType, + number: args.projectNumber, + title: project.title, + url: project.url + }, + selectedView, + rows: items.rows, + totalCount: items.totalCount, + parentFieldDropped: items.parentFieldDropped + } + return { ok: true, data: table } +} + +// ─── listAccessibleProjects ──────────────────────────────────────────── + +type RawViewerDiscovery = { + viewer?: { + login?: string + projectsV2?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ + id?: string + number?: number + title?: string + url?: string + owner?: { __typename?: string; login?: string } + } | null)[] + } + organizations?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ + login?: string + projectsV2?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ id?: string; number?: number; title?: string; url?: string } | null)[] + } + } | null)[] + } + } +} + +export async function listAccessibleProjects(): Promise { + const viewerProjects: GitHubProjectSummary[] = [] + const orgProjects: GitHubProjectSummary[] = [] + // Why: per-org failures are collected so the picker can render a "some orgs + // didn't load" banner with the affected logins, instead of aborting the + // whole discovery on the first 504. Users with flaky org fetches still get + // viewer + other orgs in the list. + const partialFailures: { owner: string; message: string }[] = [] + let viewerLogin: string | null = null + + // 1) Viewer projects (paginated, single owner so cap at DISCOVERY_PROJECTS_PER_OWNER total). + let viewerCursor: string | null = null + let viewerMore = true + let viewerFetched = 0 + while (viewerMore && viewerFetched < DISCOVERY_PROJECTS_PER_OWNER) { + const afterArg = viewerCursor ? ', after: $after' : '' + const afterVar = viewerCursor ? '$after:String!' : '' + const query = ` + query${afterVar ? `(${afterVar})` : ''} { + viewer { + login + projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + id number title url + owner { __typename ... on Organization { login } ... on User { login } } + } + } + } + } + ` + const vars: GraphqlVars = {} + if (viewerCursor) { + vars.after = viewerCursor + } + const res = await runGraphql(query, vars) + if (!res.ok) { + // Why: a viewer-level failure is structural — if we can't list the + // user's own projects, we have nothing to build on. Propagate as a + // hard error instead of returning partial. Org-level errors below + // are non-fatal because the viewer slice is still useful on its own. + return { ok: false, error: res.error } + } + if (!res.data.viewer) { + return { ok: false, error: driftError('viewer missing') } + } + if (viewerLogin === null) { + viewerLogin = res.data.viewer.login ?? null + } + const nodes = res.data.viewer.projectsV2?.nodes ?? [] + for (const n of nodes) { + if (!n || typeof n.id !== 'string' || typeof n.number !== 'number') { + continue + } + const ownerLogin = n.owner?.login ?? viewerLogin ?? '' + const ownerType: GitHubProjectOwnerType = + n.owner?.__typename === 'Organization' ? 'organization' : 'user' + viewerProjects.push({ + id: n.id, + owner: ownerLogin, + ownerType, + number: n.number, + title: n.title ?? '', + url: n.url ?? '', + source: 'viewer' + }) + viewerFetched++ + if (viewerFetched >= DISCOVERY_PROJECTS_PER_OWNER) { + break + } + } + const pi = res.data.viewer.projectsV2?.pageInfo + viewerMore = pi?.hasNextPage === true && typeof pi.endCursor === 'string' + viewerCursor = viewerMore ? (pi?.endCursor ?? null) : null + } + + // 2) Organizations the viewer belongs to, each with its projectsV2. + // Why: we intentionally drop the per-org continuation loop that previously + // ran `organization(login).projectsV2(first:50, after:$after)` when the + // first nested page had more. That inner loop was the dominant cost + // multiplier and the most common 504 source — a single slow org would + // serially block the picker for tens of seconds. Users with more than + // DISCOVERY_PROJECTS_PER_ORG projects in a given org can still paste a + // URL to reach them; the picker is discovery, not an exhaustive index. + let orgCursor: string | null = null + let orgMore = true + let orgsSeen = 0 + while (orgMore && orgsSeen < DISCOVERY_MAX_ORGS) { + const afterArg = orgCursor ? ', after: $orgAfter' : '' + const afterVar = orgCursor ? '$orgAfter:String!' : '' + const query = ` + query${afterVar ? `(${afterVar})` : ''} { + viewer { + organizations(first:${DISCOVERY_ORG_PAGE_SIZE}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + login + projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}) { + pageInfo { hasNextPage endCursor } + nodes { id number title url } + } + } + } + } + } + ` + const vars: GraphqlVars = {} + if (orgCursor) { + vars.orgAfter = orgCursor + } + const res = await runGraphql(query, vars) + if (!res.ok) { + // Why: the org-listing query itself failed (not a nested projectsV2). + // Record it as a partial failure against a synthetic `*` owner so the + // UI banner explains why additional orgs aren't listed, but keep any + // viewer projects we already collected. This is the critical 504 path + // the user reported in the ProjectPicker. + partialFailures.push({ owner: '*', message: res.error.message }) + break + } + const orgs = res.data.viewer?.organizations?.nodes ?? [] + for (const org of orgs) { + if (!org || typeof org.login !== 'string') { + continue + } + if (orgsSeen >= DISCOVERY_MAX_ORGS) { + break + } + orgsSeen++ + const login = org.login + // Cache owner → ownerType for downstream paste/resolve even when the + // nested projects query was empty or partially failed — paste-to-add + // uses this to disambiguate /orgs/ vs /users/ URLs. + ownerTypeCache.set(login, 'organization') + const nodes = org.projectsV2?.nodes ?? [] + let ownerCount = 0 + for (const n of nodes) { + if (!n || typeof n.id !== 'string' || typeof n.number !== 'number') { + continue + } + if (ownerCount >= DISCOVERY_PROJECTS_PER_OWNER) { + break + } + orgProjects.push({ + id: n.id, + owner: login, + ownerType: 'organization', + number: n.number, + title: n.title ?? '', + url: n.url ?? '', + source: `org:${login}` + }) + ownerCount++ + } + } + const pi = res.data.viewer?.organizations?.pageInfo + orgMore = pi?.hasNextPage === true && typeof pi.endCursor === 'string' + orgCursor = orgMore ? (pi?.endCursor ?? null) : null + } + + if (viewerLogin) { + ownerTypeCache.set(viewerLogin, 'user') + } + + return { + ok: true, + projects: [...viewerProjects, ...orgProjects], + ...(partialFailures.length > 0 ? { partialFailures } : {}) + } +} + +// ─── resolveProjectRef ───────────────────────────────────────────────── + +type ParsedPaste = + | { kind: 'org'; owner: string; number: number; viewNumber?: number } + | { kind: 'user'; owner: string; number: number; viewNumber?: number } + | { kind: 'bare'; owner: string; number: number } + +export function parseProjectPaste(input: string): ParsedPaste | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + // URL forms + const urlRe = + /^https?:\/\/github\.com\/(orgs|users)\/([^/]+)\/projects\/(\d+)(?:\/views\/(\d+))?/i + const m = trimmed.match(urlRe) + if (m) { + const [, kindSeg, owner, nStr, vStr] = m + const number = parseInt(nStr, 10) + if (!Number.isInteger(number) || number < 1) { + return null + } + if (!isValidOwnerSlug(owner)) { + return null + } + const viewNumber = vStr ? parseInt(vStr, 10) : undefined + return { + kind: kindSeg === 'orgs' ? 'org' : 'user', + owner, + number, + ...(viewNumber !== undefined && Number.isInteger(viewNumber) && viewNumber >= 1 + ? { viewNumber } + : {}) + } + } + // owner/number shorthand — owner alphabet matches OWNER_SLUG_RE. + const shortRe = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/ + const sm = trimmed.match(shortRe) + if (sm) { + const number = parseInt(sm[2], 10) + if (!Number.isInteger(number) || number < 1) { + return null + } + return { kind: 'bare', owner: sm[1], number } + } + return null +} + +async function resolveOwnerType( + owner: string, + preferred: GitHubProjectOwnerType | null +): Promise< + | { ok: true; ownerType: GitHubProjectOwnerType; title: string } + | { ok: false; error: GitHubProjectViewError } +> { + const tryOne = async ( + ot: GitHubProjectOwnerType, + num: number | null + ): Promise<{ ok: true; title: string } | { ok: false; error: GitHubProjectViewError }> => { + const root = ownerQueryRoot(ot) + // If number is provided, fetch the project title; else just confirm owner exists. + const query = num + ? ` + query($owner:String!, $num:Int!) { + ${root}(login:$owner) { projectV2(number:$num) { id title } } + } + ` + : ` + query($owner:String!) { + ${root}(login:$owner) { login } + } + ` + const vars: GraphqlVars = { owner } + if (num) { + vars.num = num + } + const res = await runGraphql< + Record + >(query, vars) + if (!res.ok) { + return { ok: false, error: res.error } + } + const top = res.data[root] + if (!top) { + return { ok: false, error: { type: 'not_found', message: 'Owner not found.' } } + } + if (num) { + const p = top.projectV2 + if (!p || typeof p.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + return { ok: true, title: p.title ?? '' } + } + return { ok: true, title: '' } + } + + const cached = ownerTypeCache.get(owner) + const candidates: GitHubProjectOwnerType[] = preferred + ? [preferred] + : cached + ? [cached] + : ['organization', 'user'] + const fallback: GitHubProjectOwnerType[] = preferred + ? [] + : cached + ? cached === 'organization' + ? ['user'] + : ['organization'] + : [] + const ordered = [...candidates, ...fallback] + let lastError: GitHubProjectViewError | null = null + for (const ot of ordered) { + const r = await tryOne(ot, null) + if (r.ok) { + ownerTypeCache.set(owner, ot) + return { ok: true, ownerType: ot, title: r.title } + } + lastError = r.error + if (r.error.type !== 'not_found') { + // Non-NOT_FOUND errors (auth, network, rate) should not trigger fallback. + return { ok: false, error: r.error } + } + } + ownerTypeCache.set(owner, null) + return { + ok: false, + error: lastError ?? { type: 'not_found', message: 'Owner not found.' } + } +} + +export async function resolveProjectRef( + args: ResolveProjectRefArgs +): Promise { + if (typeof args.input !== 'string' || !args.input.trim()) { + return { + ok: false, + error: { type: 'validation_error', message: 'Input required.' } + } + } + const parsed = parseProjectPaste(args.input) + if (!parsed) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Could not parse input. Expected a GitHub project URL or `owner/number`.' + } + } + } + const preferred: GitHubProjectOwnerType | null = + parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null + // Verify by fetching project title. + const ownerRes = await resolveOwnerType(parsed.owner, preferred) + if (!ownerRes.ok) { + return { ok: false, error: ownerRes.error } + } + const ownerType = ownerRes.ownerType + const root = ownerQueryRoot(ownerType) + const query = ` + query($owner:String!, $num:Int!) { + ${root}(login:$owner) { projectV2(number:$num) { id title } } + } + ` + const res = await runGraphql< + Record + >(query, { owner: parsed.owner, num: parsed.number }) + if (!res.ok) { + return { ok: false, error: res.error } + } + const p = res.data[root]?.projectV2 + if (!p || typeof p.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + return { + ok: true, + owner: parsed.owner, + ownerType, + number: parsed.number, + title: p.title ?? '', + // Why: forward the parsed view number from /views/{n} URLs so the + // renderer can skip the view-pick step. parsed.kind === 'bare' has no + // viewNumber (owner/number shorthand carries no view). + ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined + ? { viewNumber: parsed.viewNumber } + : {}) + } +} + +// ─── listProjectViews ────────────────────────────────────────────────── + +export async function listProjectViews( + args: ListProjectViewsArgs +): Promise { + const ownerCheck = assertSlug(args.owner, 'owner') + if (!ownerCheck.ok) { + return { ok: false, error: ownerCheck.error } + } + const numCheck = assertPositiveInt(args.projectNumber, 'projectNumber') + if (!numCheck.ok) { + return { ok: false, error: numCheck.error } + } + if (args.ownerType !== 'organization' && args.ownerType !== 'user') { + return { ok: false, error: { type: 'validation_error', message: 'Invalid ownerType.' } } + } + const summaries: GitHubProjectViewSummary[] = [] + let cursor: string | null = null + while (true) { + const page = await fetchProjectViewsPage({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + after: cursor + }) + if (!page.ok) { + return { ok: false, error: page.error } + } + for (const v of page.views) { + if (typeof v.id !== 'string' || typeof v.layout !== 'string') { + continue + } + summaries.push({ + id: v.id, + number: typeof v.number === 'number' ? v.number : 0, + name: typeof v.name === 'string' ? v.name : '', + layout: v.layout as GitHubProjectViewLayout + }) + } + if (!page.hasNextPage) { + break + } + cursor = page.endCursor + if (typeof cursor !== 'string') { + break + } + } + return { ok: true, views: summaries } +} diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts new file mode 100644 index 00000000000..52509c30652 --- /dev/null +++ b/src/main/github/project-view/internals.ts @@ -0,0 +1,378 @@ +/* eslint-disable max-lines -- Why: shared infrastructure for project-view — +slug validation, error classification, runGraphql/runRest, and rate-limit +synthesis. Co-located so the read and write paths observe identical +classification semantics. */ +// Why: `ghExecFileAsync` (WSL-aware, retry-enabled) is the single spawn site +// for gh calls. The legacy plain `execFileAsync` is NOT used here — routing +// every gh call through the runner gives us transient-5xx retry, WSL path +// translation, and a single hook point for future quota tracking. +import { acquire, release } from '../gh-utils' +import { extractExecError, ghExecFileAsync } from '../../git/runner' +import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit' +import type { GitHubProjectViewError } from '../../../shared/github-project-types' + +export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend } +export type { RateLimitBucketKind } + +// ─── Slug validation ────────────────────────────────────────────────── + +// Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names +// are looser — they allow leading `_`, `.`, `-` (`.` and `..` reserved). We +// validate each separately so untrusted Project row data (`nameWithOwner`) +// can't become an arbitrary REST path while still accepting realistic repo +// names like `_internal` or `.github`. +const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ +const REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ +const REPO_SLUG_RESERVED = new Set(['.', '..']) + +export function isValidOwnerSlug(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && OWNER_SLUG_RE.test(value) +} + +export function isValidRepoSlug(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + REPO_SLUG_RE.test(value) && + !REPO_SLUG_RESERVED.has(value) + ) +} + +// Backwards-compatible alias for callers that don't distinguish owner vs repo. +// Prefer `isValidOwnerSlug` / `isValidRepoSlug` at new call sites. +export function isValidSlug(value: unknown): value is string { + return isValidOwnerSlug(value) || isValidRepoSlug(value) +} + +export function assertSlug( + value: unknown, + field: 'owner' | 'repo' +): { ok: true; slug: string } | { ok: false; error: GitHubProjectViewError } { + const valid = field === 'owner' ? isValidOwnerSlug(value) : isValidRepoSlug(value) + if (!valid) { + return { + ok: false, + error: { + type: 'validation_error', + message: `Invalid ${field}: "${String(value)}" is not a valid GitHub slug.` + } + } + } + return { ok: true, slug: value as string } +} + +export function assertPositiveInt( + value: unknown, + field: string +): { ok: true; n: number } | { ok: false; error: GitHubProjectViewError } { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + return { + ok: false, + error: { + type: 'validation_error', + message: `Invalid ${field}: must be a positive integer.` + } + } + } + return { ok: true, n: value } +} + +export function validateSlugArgs( + owner: unknown, + repo: unknown +): { ok: true } | { ok: false; error: GitHubProjectViewError } { + const o = assertSlug(owner, 'owner') + if (!o.ok) { + return { ok: false, error: o.error } + } + const r = assertSlug(repo, 'repo') + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +// ─── Error classification ────────────────────────────────────────────── + +export type GhGraphqlErrorShape = { + type?: string + message?: string + path?: (string | number)[] + extensions?: { code?: string } +} + +export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { + // `gh api graphql` prints the response JSON to stdout even on GraphQL + // errors, and the stderr carries a summary. Try stdout first; if parsing + // fails, fall back to stderr. + const sources = [stdout, stderr] + for (const src of sources) { + if (!src) { + continue + } + try { + const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return parsed.errors + } + } catch { + // not JSON — continue + } + } + return [] +} + +export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean { + const lower = stderr.toLowerCase() + // Preview-header shape: gh returns a 4xx with "preview" in the message. + if (lower.includes('preview') && lower.includes('parent')) { + return true + } + return errors.some((e) => { + const type = (e.type ?? '').toUpperCase() + if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { + const tail = e.path?.at(-1) + if (tail === 'parent') { + return true + } + // FIELD_ERRORS often omits `path`; match on message for the parent field. + if ((e.message ?? '').toLowerCase().includes('parent')) { + return true + } + } + return false + }) +} + +export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError { + const errors = extractGraphqlErrors(stderr, stdout) + const s = stderr.toLowerCase() + + // Auth + if ( + s.includes('authentication required') || + s.includes('not logged in') || + s.includes('gh auth login') + ) { + return { + type: 'auth_required', + message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.' + } + } + // Scope + if ( + s.includes('missing required scope') || + s.includes('your token has not been granted') || + (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) + ) { + return { + type: 'scope_missing', + message: + 'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.' + } + } + // Rate limit + if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { + return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } + } + // Network — checked BEFORE not_found because DNS failures surface as + // "could not resolve host", which would otherwise be partially matched by + // the not_found branch's "could not resolve" check. Substring matching here + // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" + // error always contains "to a", so we tighten the not_found check below to + // require that token. + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') || + s.includes('dial tcp') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + // Not found + if ( + s.includes('http 404') || + errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || + // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to + // an Organization") and "to a" otherwise. The previous singular-only + // check missed the "an" variants when gh emits only the stderr summary + // without a structured GraphQL error array. See bug-scan finding 3. + /could not resolve to an? /.test(s) + ) { + const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') + return { + type: 'not_found', + message: 'Project or view not found.', + details: firstNotFound + ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } + : undefined + } + } + // Validation + if (s.includes('http 422') || s.includes('validation failed')) { + return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } + } + // GraphQL error with structured info + if (errors.length > 0) { + const first = errors[0] + return { + type: 'unknown', + message: first.message ?? 'Unknown GraphQL error.', + details: { path: first.path, code: first.extensions?.code } + } + } + // Why: don't leak full stderr to the UI — it can include verbose request + // dumps with header diagnostics. Truncate to the first non-empty line and + // cap length so unexpected diagnostics stay readable but bounded. + const firstLine = + stderr + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? '' + const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine + return { + type: 'unknown', + message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' + } +} + +export function driftError( + reason: string, + details?: { path?: (string | number)[]; code?: string } +): GitHubProjectViewError { + return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } +} + +// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` +// when the cached snapshot says we're below the safety floor. Synthesize the +// same `rate_limited` error shape as the post-hoc classifier so the UI path +// is unchanged. We DO NOT fail open here when there's no cached snapshot — +// rateLimitGuard already handles that case (returns `blocked:false`). +export function rateLimitedError(blocked: { + remaining: number + limit: number + resetAt: number +}): GitHubProjectViewError { + const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) + const mins = Math.ceil(resetIn / 60) + return { + type: 'rate_limited', + message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` + } +} + +// ─── Low-level gh api graphql invocation ─────────────────────────────── + +export type GraphqlVars = Record + +export async function runGraphql( + query: string, + vars: GraphqlVars, + cwd?: string +): Promise< + | { ok: true; data: T } + | { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } } +> { + const guard = rateLimitGuard('graphql') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } } + } + // Why: build argv as an array. `-f` for strings (including numbers passed + // as strings), `-F` coerces to typed. We use `-f` uniformly and coerce in + // the query via Int! casts, because `gh` can confuse empty strings. + const args: string[] = ['api', 'graphql', '-f', `query=${query}`] + for (const [k, v] of Object.entries(vars)) { + if (typeof v === 'number' || typeof v === 'boolean') { + args.push('-F', `${k}=${String(v)}`) + } else { + args.push('-f', `${k}=${v}`) + } + } + await acquire() + noteRateLimitSpend('graphql') + try { + const { stdout, stderr } = await ghExecFileAsync(args, { + encoding: 'utf-8', + ...(cwd ? { cwd } : {}) + }) + try { + const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return { + ok: false, + error: classifyProjectError(stderr, stdout), + raw: { stderr, stdout } + } + } + if (parsed.data === undefined) { + return { + ok: false, + error: driftError('response missing data'), + raw: { stderr, stdout } + } + } + return { ok: true, data: parsed.data } + } catch (parseErr) { + return { + ok: false, + error: driftError( + `failed to parse response (${parseErr instanceof Error ? parseErr.message : String(parseErr)})` + ), + raw: { stderr, stdout } + } + } + } catch (err) { + // gh executable failures (non-zero exit). Read stderr/stdout from the + // exec rejection's explicit fields — `err.message` may truncate stderr. + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { + ok: false, + error: classifyProjectError(stderr, maybeStdout), + raw: { stderr, stdout: maybeStdout } + } + } finally { + release() + } +} + +export async function runRest( + args: string[], + cwd?: string, + bucket: RateLimitBucketKind = 'core', + options?: { expectEmpty?: boolean } +): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> { + const guard = rateLimitGuard(bucket) + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + noteRateLimitSpend(bucket) + try { + const { stdout, stderr } = await ghExecFileAsync(['api', ...args], { + encoding: 'utf-8', + ...(cwd ? { cwd } : {}) + }) + // Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no + // body. Treat empty stdout as success rather than misclassifying the + // unparseable response as 'unknown' — which the caller would otherwise + // need to special-case and risks masking real failures whose stderr the + // classifier also tags as 'unknown'. + if (options?.expectEmpty && stdout.trim() === '') { + return { ok: true, data: undefined as T } + } + try { + return { ok: true, data: JSON.parse(stdout) as T } + } catch { + return { + ok: false, + error: { type: 'unknown', message: `Unexpected REST response: ${stderr.trim()}` } + } + } + } catch (err) { + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + } finally { + release() + } +} diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts new file mode 100644 index 00000000000..cd4e0944fc9 --- /dev/null +++ b/src/main/github/project-view/mutations.ts @@ -0,0 +1,841 @@ +/* eslint-disable max-lines -- Why: slug-addressed mutations + work-item +details share the validate/runRest/runGraphql plumbing with the read path. +Keeping them together preserves a single review surface for the write side. */ +import { + acquire, + release, + extractExecError, + ghExecFileAsync, + rateLimitGuard, + noteRateLimitSpend, + classifyProjectError, + rateLimitedError, + runGraphql, + runRest, + validateSlugArgs, + assertPositiveInt, + type GraphqlVars +} from './internals' +import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GitHubProjectCommentMutationResult, + GitHubProjectFieldMutationValue, + GitHubProjectMutationResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../../../shared/github-project-types' + +// ─── Project field mutations ────────────────────────────────────────── + +class UnknownFieldMutationKindError extends Error { + constructor(kind: string) { + super(`Unknown project field mutation kind: ${kind}`) + } +} + +function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { + // Serialize the value fragment for the GraphQL mutation. We use GraphQL + // variables for every dynamic piece, so here we only pick the variable name + // to reference per value kind. + switch (value.kind) { + case 'single-select': + return 'singleSelectOptionId: $value' + case 'iteration': + return 'iterationId: $value' + case 'text': + return 'text: $value' + case 'number': + return 'number: $value' + case 'date': + return 'date: $value' + default: + // Why: defensive default. If a new mutation kind is added to the type + // but not handled here, returning undefined would silently produce a + // broken GraphQL query. Throw so updateProjectItemFieldValue can map it + // to a validation_error rather than dispatching a malformed mutation. + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) + } +} + +function mutationValueVar(value: GitHubProjectFieldMutationValue): { + type: string + val: string | number +} { + switch (value.kind) { + case 'single-select': + return { type: 'String!', val: value.optionId } + case 'iteration': + return { type: 'String!', val: value.iterationId } + case 'text': + return { type: 'String!', val: value.text } + case 'number': + return { type: 'Float!', val: value.number } + case 'date': + return { type: 'Date!', val: value.date } + default: + // Why: see graphqlValueForFieldMutation — surface unknown kinds loudly + // instead of returning undefined and dispatching an invalid mutation. + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) + } +} + +export async function updateProjectItemFieldValue( + args: UpdateProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + let valFrag: string + let valVar: { type: string; val: string | number } + try { + valFrag = graphqlValueForFieldMutation(args.value) + valVar = mutationValueVar(args.value) + } catch (err) { + if (err instanceof UnknownFieldMutationKindError) { + return { ok: false, error: { type: 'validation_error', message: err.message } } + } + throw err + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { ${valFrag} } + }) { projectV2Item { id } } + } + ` + const vars: GraphqlVars = { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId, + value: valVar.val + } + const res = await runGraphql(query, vars) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} + +export async function clearProjectItemFieldValue( + args: ClearProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { + clearProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + }) { projectV2Item { id } } + } + ` + const res = await runGraphql(query, { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId + }) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} + +// ─── Slug-addressed issue/PR mutations ──────────────────────────────── + +export async function updateIssueBySlug( + args: UpdateIssueBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } = + args.updates + + // Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}. + // Labels/assignees go through their dedicated endpoints. + const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` + + // 1) PATCH body + if (title !== undefined || body !== undefined || state !== undefined) { + const patchArgs: string[] = ['-X', 'PATCH', base] + if (title !== undefined) { + patchArgs.push('--raw-field', `title=${title}`) + } + if (body !== undefined) { + patchArgs.push('--raw-field', `body=${body}`) + } + if (state !== undefined) { + patchArgs.push('--raw-field', `state=${state}`) + } + const r = await runRest(patchArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + + // 2) Labels — collapse multi-delete fan-out into a single PUT when removing + // >1 label. PUT /labels replaces the entire label set, so we fetch the + // current labels first and compute the resulting set client-side. This + // turns an N-delete + 1-add (=N+1 calls) into 1-fetch + 1-PUT (=2 calls) + // once removeLabels has more than one entry, capping the cost at 2 even + // for a "remove all 20 labels" mutation. + const removeCount = removeLabels?.length ?? 0 + const addCount = addLabels?.length ?? 0 + if (removeCount > 1) { + type RawLabelResp = { name?: string }[] + const fetched = await runRest(['-X', 'GET', `${base}/labels`]) + if (!fetched.ok) { + return { ok: false, error: fetched.error } + } + const currentNames = new Set( + fetched.data.map((l) => l.name).filter((n): n is string => typeof n === 'string') + ) + for (const l of removeLabels ?? []) { + currentNames.delete(l) + } + for (const l of addLabels ?? []) { + currentNames.add(l) + } + if (currentNames.size === 0) { + // Why: `gh api -X PUT` with no `--raw-field` arguments sends an empty + // body — GitHub does NOT interpret that as "clear labels". The + // dedicated DELETE endpoint is the documented way to remove all + // labels in a single call. + const r = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { + expectEmpty: true + }) + if (!r.ok && r.error.type !== 'not_found') { + return { ok: false, error: r.error } + } + } else { + const putArgs = ['-X', 'PUT', `${base}/labels`] + for (const name of currentNames) { + putArgs.push('--raw-field', `labels[]=${name}`) + } + const r = await runRest(putArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + } else { + if (addCount > 0) { + const restArgs = ['-X', 'POST', `${base}/labels`] + for (const l of addLabels ?? []) { + restArgs.push('--raw-field', `labels[]=${l}`) + } + const r = await runRest(restArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + if (removeCount === 1) { + const r = await runRest( + ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`], + undefined, + 'core', + { expectEmpty: true } + ) + if (!r.ok && r.error.type !== 'not_found') { + return { ok: false, error: r.error } + } + } + } + + // 3) Assignees — POST and DELETE both accept arrays in a single call, so + // add/remove are at most 2 calls regardless of array size. + if (addAssignees && addAssignees.length > 0) { + const restArgs = ['-X', 'POST', `${base}/assignees`] + for (const u of addAssignees) { + restArgs.push('--raw-field', `assignees[]=${u}`) + } + const r = await runRest(restArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + if (removeAssignees && removeAssignees.length > 0) { + const restArgs = ['-X', 'DELETE', `${base}/assignees`] + for (const u of removeAssignees) { + restArgs.push('--raw-field', `assignees[]=${u}`) + } + const r = await runRest(restArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + } + return { ok: true } +} + +export async function updatePullRequestBySlug( + args: UpdatePullRequestBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const patchArgs: string[] = [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/pulls/${args.number}` + ] + // Why: count fields explicitly rather than inferring from patchArgs.length — + // adding a future header/flag arg silently breaks an array-length check. + let fieldCount = 0 + if (args.updates.title !== undefined) { + patchArgs.push('--raw-field', `title=${args.updates.title}`) + fieldCount++ + } + if (args.updates.body !== undefined) { + patchArgs.push('--raw-field', `body=${args.updates.body}`) + fieldCount++ + } + if (fieldCount === 0) { + // No fields to update — nothing to do. + return { ok: true } + } + const r = await runRest(patchArgs) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +type RawIssueCommentResponse = { + id?: number + user?: { login?: string; avatar_url?: string; type?: string } | null + body?: string + created_at?: string + html_url?: string +} + +function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { + return { + id: data.id ?? Date.now(), + author: data.user?.login ?? 'You', + authorAvatarUrl: data.user?.avatar_url ?? '', + body: data.body ?? fallbackBody, + createdAt: data.created_at ?? new Date().toISOString(), + url: data.html_url ?? '', + isBot: data.user?.type === 'Bot' + } +} + +export async function addIssueCommentBySlug( + args: AddIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const r = await runRest([ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ]) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true, comment: mapIssueComment(r.data, args.body) } +} + +export async function updateIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.commentId, 'commentId') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const r = await runRest([ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ]) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +export async function deleteIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.commentId, 'commentId') + if (!n.ok) { + return { ok: false, error: n.error } + } + const r = await runRest( + ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], + undefined, + 'core', + { expectEmpty: true } + ) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +// ─── Slug-addressed picker sources ──────────────────────────────────── + +export async function listLabelsBySlug( + args: ListLabelsBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const guard = rateLimitGuard('core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + // Why: `--paginate` may fan out to multiple pages; we can only reasonably + // estimate a 1-call spend up front. The next probe will reconcile. + noteRateLimitSpend('core') + try { + const { stdout } = await ghExecFileAsync( + ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], + { encoding: 'utf-8' } + ) + return { + ok: true, + labels: stdout + .trim() + .split('\n') + .filter((l) => l.length > 0) + } + } catch (err) { + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + } finally { + release() + } +} + +export async function listAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + // Seed logins merge after the fetch so callers can include currently-visible + // assignees even if the repo participant search is sparse. + const result: GitHubAssignableUser[] = [] + const guard = rateLimitGuard('core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + noteRateLimitSpend('core') + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--paginate', + `repos/${args.owner}/${args.repo}/assignees`, + '--jq', + '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' + ], + { encoding: 'utf-8' } + ) + for (const line of stdout + .trim() + .split('\n') + .filter((l) => l.length > 0)) { + try { + const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null } + if (typeof u.login === 'string') { + result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' }) + } + } catch { + // skip malformed jq line + } + } + } catch (err) { + const { stderr } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, '') } + } finally { + release() + } + if (args.seedLogins) { + const seen = new Set(result.map((u) => u.login)) + for (const login of args.seedLogins) { + if (typeof login === 'string' && !seen.has(login)) { + result.push({ login, name: null, avatarUrl: '' }) + seen.add(login) + } + } + } + return { ok: true, users: result } +} + +// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only +// available on repos opted into typed-issues. Empty list (or schema_drift on +// older GitHub deployments) is the legitimate "this repo doesn't use issue +// types" signal — callers should treat it as "no editor". +export async function listIssueTypesBySlug( + args: ListIssueTypesBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const query = ` + query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { + issueTypes(first:50) { + nodes { id name color description } + } + } + } + ` + const res = await runGraphql<{ + repository?: { + issueTypes?: { + nodes?: ({ + id?: string + name?: string + color?: string | null + description?: string | null + } | null)[] + } | null + } | null + }>(query, { owner: args.owner, repo: args.repo }) + if (!res.ok) { + // Why: repos without issue types respond with a GraphQL error claiming the + // `issueTypes` field is unknown. Map that to an empty list so the UI shows + // "no editor" instead of an angry banner. + if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') { + return { ok: true, types: [] } + } + return { ok: false, error: res.error } + } + const nodes = res.data.repository?.issueTypes?.nodes ?? [] + const types = nodes + .filter( + (n): n is NonNullable => + n !== null && typeof n.id === 'string' && typeof n.name === 'string' + ) + .map((n) => ({ + id: n.id as string, + name: n.name as string, + color: typeof n.color === 'string' ? n.color : null, + description: typeof n.description === 'string' ? n.description : null + })) + return { ok: true, types } +} + +export async function updateIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + // Why: `updateIssueIssueType` is the dedicated mutation; passing null for + // `issueTypeId` clears the type. We resolve the issue id via a lightweight + // GraphQL lookup because the REST endpoint doesn't accept issue types. + const lookup = await runGraphql<{ + repository?: { issue?: { id?: string } | null } | null + }>( + `query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { issue(number:$num) { id } } + }`, + { owner: args.owner, repo: args.repo, num: args.number } + ) + if (!lookup.ok) { + return { ok: false, error: lookup.error } + } + const issueId = lookup.data.repository?.issue?.id + if (!issueId) { + return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } + } + // Why: build the mutation conditionally so a null clear doesn't have to + // smuggle a null GraphQL variable through `gh api graphql -f`. The + // mutation accepts a literal `null` in the input object directly. + const query = args.issueTypeId + ? ` + mutation($issueId:ID!, $issueTypeId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { + issue { id } + } + } + ` + : ` + mutation($issueId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { + issue { id } + } + } + ` + const vars: GraphqlVars = args.issueTypeId + ? { issueId, issueTypeId: args.issueTypeId } + : { issueId } + const res = await runGraphql(query, vars) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} + +// ─── Slug-addressed work-item details ───────────────────────────────── + +type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } +type RawLabel = { name?: string; color?: string } +type RawWorkItemContent = { + id?: string + number?: number + title?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + labels?: { nodes?: RawLabel[] } + assignees?: { nodes?: RawUser[] } +} + +export async function getWorkItemDetailsBySlug( + args: ProjectWorkItemDetailsBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (args.type !== 'issue' && args.type !== 'pr') { + return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } + } + + // Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees. + const contentFrag = + args.type === 'issue' + ? ` + issue(number:$num) { + id number title url state stateReason updatedAt + body + author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { + databaseId + author { login avatarUrl __typename } + body createdAt url + } + } + } + ` + : ` + pullRequest(number:$num) { + id number title url state isDraft updatedAt headRefName baseRefName + body + author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { + databaseId + author { login avatarUrl __typename } + body createdAt url + } + } + } + ` + const query = ` + query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { + ${contentFrag} + } + } + ` + const res = await runGraphql<{ + repository?: { + issue?: + | (RawWorkItemContent & { + updatedAt?: string + body?: string + author?: { login?: string } | null + participants?: { nodes?: RawUser[] } + comments?: { + nodes?: ({ + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string + } | null)[] + } + }) + | null + pullRequest?: + | (RawWorkItemContent & { + updatedAt?: string + body?: string + headRefName?: string + baseRefName?: string + author?: { login?: string } | null + participants?: { nodes?: RawUser[] } + comments?: { + nodes?: ({ + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string + } | null)[] + } + }) + | null + } | null + }>(query, { owner: args.owner, repo: args.repo, num: args.number }) + if (!res.ok) { + return { ok: false, error: res.error } + } + const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest + if (!raw) { + return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } + } + + const labels = (raw.labels?.nodes ?? []) + .map((l) => l?.name) + .filter((n): n is string => typeof n === 'string') + const assignees = (raw.assignees?.nodes ?? []) + .map((a) => a?.login) + .filter((l): l is string => typeof l === 'string') + const comments: PRComment[] = [] + for (const c of raw.comments?.nodes ?? []) { + if (!c || typeof c.body !== 'string') { + continue + } + comments.push({ + id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(), + author: c.author?.login ?? '', + authorAvatarUrl: c.author?.avatarUrl ?? '', + body: c.body, + createdAt: typeof c.createdAt === 'string' ? c.createdAt : '', + url: typeof c.url === 'string' ? c.url : '', + isBot: c.author?.__typename === 'Bot' + }) + } + const participants: GitHubAssignableUser[] = [] + for (const p of raw.participants?.nodes ?? []) { + if (p && typeof p.login === 'string') { + participants.push({ login: p.login, name: p.name ?? null, avatarUrl: p.avatarUrl ?? '' }) + } + } + + const state: 'open' | 'closed' | 'merged' | 'draft' = + args.type === 'pr' + ? raw.isDraft + ? 'draft' + : raw.state === 'MERGED' + ? 'merged' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + + const details: GitHubWorkItemDetails = { + item: { + id: typeof raw.id === 'string' ? raw.id : '', + type: args.type, + number: typeof raw.number === 'number' ? raw.number : args.number, + title: typeof raw.title === 'string' ? raw.title : '', + state, + url: typeof raw.url === 'string' ? raw.url : '', + labels, + updatedAt: + typeof (raw as { updatedAt?: string }).updatedAt === 'string' + ? (raw as { updatedAt: string }).updatedAt + : '', + author: + typeof (raw as { author?: { login?: string } | null }).author?.login === 'string' + ? ((raw as { author: { login: string } }).author.login as string) + : null, + branchName: + args.type === 'pr' && typeof (raw as { headRefName?: string }).headRefName === 'string' + ? ((raw as { headRefName: string }).headRefName as string) + : undefined, + baseRefName: + args.type === 'pr' && typeof (raw as { baseRefName?: string }).baseRefName === 'string' + ? ((raw as { baseRefName: string }).baseRefName as string) + : undefined + }, + body: typeof raw.body === 'string' ? raw.body : '', + comments, + participants, + // Why: PR files/checks/review-thread tabs depend on a local repo path and + // are out of Project-mode slug scope for v1. Omit them here; the dialog + // branches on their absence and hides those tabs. + ...(args.type === 'issue' ? { assignees } : {}) + } + return { ok: true, details } +} diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts new file mode 100644 index 00000000000..1108b6bd219 --- /dev/null +++ b/src/main/github/rate-limit.ts @@ -0,0 +1,155 @@ +/** + * GitHub API rate-limit probe. + * + * Why: `listWorkItems` fan-out × selected repos plus `countWorkItems` in + * parallel, plus `listAccessibleProjects` org-walk, can chew through the + * core (5000/hr) or search (30/min) buckets quickly. Surfacing the remaining + * budget in the TaskPage header lets users self-regulate before they hit the + * wall — without actually throttling (which would hurt responsiveness in + * the common not-near-the-limit case). The probe itself is exempt from + * rate-limit accounting per GitHub docs. + * + * The result is intentionally minimal: we expose just the counts the UI + * needs (remaining + limit for the three buckets we actually stress). If a + * future feature needs reset-time countdowns we can add resetAt here. + */ +import type { + GetRateLimitResult, + GitHubRateLimitBucket, + GitHubRateLimitSnapshot +} from '../../shared/types' +import { acquire, release } from './gh-utils' +import { ghExecFileAsync } from '../git/runner' + +// Why: GitHub explicitly states `GET /rate_limit` does NOT count against +// any bucket, so the only reason to cache is to avoid spawning a `gh` +// subprocess on every render. 30s is a pragmatic balance — short enough +// that the number in the header feels live, long enough to absorb the +// 1-per-second "is it safe now?" polling pattern UIs tend to fall into. +const RATE_LIMIT_CACHE_TTL_MS = 30_000 +let cached: GitHubRateLimitSnapshot | null = null + +type GhRateLimitPayload = { + resources?: { + core?: { limit?: number; remaining?: number; reset?: number } + search?: { limit?: number; remaining?: number; reset?: number } + graphql?: { limit?: number; remaining?: number; reset?: number } + } +} + +function parseBucket( + raw: + | { + limit?: number + remaining?: number + reset?: number + } + | undefined +): GitHubRateLimitBucket { + // Why: if a bucket is absent from the response (old gh, partial response), + // return 0/0/now so the UI shows a clear "unknown" state (0/0 is + // unambiguous) rather than a misleading "plenty left" fallback. + return { + limit: typeof raw?.limit === 'number' ? raw.limit : 0, + remaining: typeof raw?.remaining === 'number' ? raw.remaining : 0, + resetAt: typeof raw?.reset === 'number' ? raw.reset : Math.floor(Date.now() / 1000) + } +} + +/** @internal — test-only */ +export function _resetRateLimitCache(): void { + cached = null +} + +// Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new +// gh request when the cached snapshot says the relevant bucket is below this +// floor. Numbers chosen as "enough budget for one user-initiated flow": +// - core/graphql at 50: a typical work-item details fetch + a few mutations +// - search at 2: a search-driven view paginates in chunks of 1; 2 leaves the +// user one safety click without tipping into the 30/min hard limit +// Below the floor, callers get a synthesized rate_limited error and never +// spawn a gh subprocess. +const MIN_REMAINING_CORE = 50 +const MIN_REMAINING_GRAPHQL = 50 +const MIN_REMAINING_SEARCH = 2 + +export type RateLimitBucketKind = 'core' | 'graphql' | 'search' + +/** + * Return a "soft" stop reason if we should refuse to issue a new gh request + * for the given bucket. Returns null when there's no cached snapshot (we + * haven't probed yet — fail open) or when the bucket has enough budget left. + * + * Why: this is the proactive guard the pill alone cannot provide. The pill is + * informational; this function actually blocks the spawn. We deliberately keep + * it advisory (returns a reason, doesn't throw) so callers can format the + * envelope/error in their own shape. + */ +export function rateLimitGuard(bucket: RateLimitBucketKind): + | { blocked: false } + | { + blocked: true + remaining: number + limit: number + resetAt: number + } { + if (!cached) { + return { blocked: false } + } + const b = cached[bucket] + const floor = + bucket === 'core' + ? MIN_REMAINING_CORE + : bucket === 'graphql' + ? MIN_REMAINING_GRAPHQL + : MIN_REMAINING_SEARCH + // Why: only block when we have a positive limit (limit:0 means "unknown" per + // parseBucket fallback — don't block on missing data, that would brick the + // app on a single bad rate_limit response). + if (b.limit > 0 && b.remaining < floor) { + return { blocked: true, remaining: b.remaining, limit: b.limit, resetAt: b.resetAt } + } + return { blocked: false } +} + +/** + * Decrement the cached `remaining` counter for a bucket after a successful + * spawn. Why: the canonical numbers come from the next probe, but between + * probes the cached snapshot would over-report budget if we didn't account + * for the work we just did. The decrement keeps the circuit breaker honest + * during a burst (e.g. paginating items) instead of waiting 30s for the cache + * to expire. + */ +export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void { + if (!cached) { + return + } + const b = cached[bucket] + if (b.remaining > 0) { + cached = { ...cached, [bucket]: { ...b, remaining: Math.max(0, b.remaining - cost) } } + } +} + +export async function getRateLimit(options?: { force?: boolean }): Promise { + if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) { + return { ok: true, snapshot: cached } + } + await acquire() + try { + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' }) + const parsed = JSON.parse(stdout) as GhRateLimitPayload + const snapshot: GitHubRateLimitSnapshot = { + core: parseBucket(parsed.resources?.core), + search: parseBucket(parsed.resources?.search), + graphql: parseBucket(parsed.resources?.graphql), + fetchedAt: Date.now() + } + cached = snapshot + return { ok: true, snapshot } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } finally { + release() + } +} diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 054eeb2df60..b6f92b7417b 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -49,7 +49,7 @@ describe('getWorkItemDetails', () => { acquireMock.mockResolvedValue(undefined) }) - it('passes row type into the lookup and fetches issue comments from the issue source', async () => { + it('uses the collapsed GraphQL issue query as the hot path', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', type: 'issue', @@ -61,21 +61,84 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'octocat' }) - getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) }) - .mockResolvedValueOnce({ stdout: '[]' }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Issue body', + assignees: { nodes: [{ login: 'jinjing' }] }, + participants: { + nodes: [{ login: 'octocat', avatarUrl: 'https://x/y', name: 'Octo Cat' }] + }, + comments: { + nodes: [ + { + databaseId: 7, + body: 'first', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://github.com/stablyai/orca/issues/923#issuecomment-7', + author: { login: 'octocat', avatarUrl: 'https://x/y' } + } + ] + } + } + } + } + }) + }) const details = await getWorkItemDetails('/repo-root', 923, 'issue') expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue') + // Why: a single gh subprocess call replaces the previous REST + REST + GraphQL fan-out. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api') + expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql') + expect(details?.body).toBe('Issue body') + expect(details?.assignees).toEqual(['jinjing']) + expect(details?.comments).toHaveLength(1) + expect(details?.comments[0].id).toBe(7) + expect(details?.participants?.[0]?.login).toBe('octocat') + }) + + it('falls back to REST + GraphQL when the collapsed issue query fails', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:923', + type: 'issue', + number: 923, + title: 'Use upstream issues', + state: 'open', + url: 'https://github.com/stablyai/orca/issues/923', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'octocat' + }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + // Collapsed GraphQL throws → fallback path picks up. + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('GraphQL error')) + .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { repository: { issue: { participants: { nodes: [] } } } } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: {} }) + }) + + const details = await getWorkItemDetails('/repo-root', 923, 'issue') + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, ['api', '--cache', '60s', 'repos/stablyai/orca/issues/923'], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['api', '--cache', '60s', 'repos/stablyai/orca/issues/923/comments?per_page=100'], { cwd: '/repo-root' } ) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index c0dbc1b469d..a0ea1983d79 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -32,6 +32,135 @@ const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $nu } }` +// Why: a single GraphQL round-trip replaces three serial gh subprocesses on +// the issue path (REST issue + REST comments + GraphQL participants). The +// previous fan-out could spawn ~3 `gh` processes per drawer-open; this drops +// it to one. We still fall back to the legacy REST+GraphQL path if the +// collapsed query throws or returns missing data — see the strict-fallback +// branch in getWorkItemDetails. +const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + body + assignees(first: 50) { nodes { login } } + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + comments(first: 100) { + nodes { + databaseId + body + createdAt + url + author { + login + avatarUrl(size: 48) + ... on Bot { __typename } + } + } + } + } + } +}` + +type GraphQLIssueDetailsResponse = { + data?: { + repository?: { + issue?: { + body?: string | null + assignees?: { nodes?: { login?: string }[] } + participants?: { nodes?: GitHubAssignableUser[] } + comments?: { + nodes?: { + databaseId?: number | null + body?: string | null + createdAt?: string | null + url?: string | null + author?: { + login?: string | null + avatarUrl?: string | null + __typename?: string + } | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] +} + +async function getIssueDetailsViaGraphQL( + repoPath: string, + issueNumber: number +): Promise<{ + body: string + comments: PRComment[] + assignees: string[] + participants: GitHubAssignableUser[] +} | null> { + const ownerRepo = await getIssueOwnerRepo(repoPath) + if (!ownerRepo) { + return null + } + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${ISSUE_DETAILS_QUERY}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-F', + `number=${issueNumber}` + ], + { cwd: repoPath } + ) + const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse + if (parsed.errors && parsed.errors.length > 0) { + // Why: any partial GraphQL error (permissions, unknown field on a fork) + // forces the strict REST fallback so the drawer never paints a half-built + // shell. The fallback path's behavior is the historical contract. + return null + } + const issue = parsed.data?.repository?.issue + if (!issue) { + return null + } + const comments: PRComment[] = (issue.comments?.nodes ?? []) + .filter((c) => typeof c.databaseId === 'number') + .map((c) => ({ + id: c.databaseId as number, + author: c.author?.login ?? 'ghost', + authorAvatarUrl: c.author?.avatarUrl ?? '', + body: c.body ?? '', + createdAt: c.createdAt ?? '', + url: c.url ?? '', + isBot: c.author?.__typename === 'Bot' + })) + const assignees = (issue.assignees?.nodes ?? []) + .map((a) => a.login) + .filter((login): login is string => Boolean(login)) + const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) + .filter((u) => Boolean(u.login)) + .map((u) => ({ + login: u.login, + name: u.name ?? null, + avatarUrl: u.avatarUrl ?? '' + })) + return { + body: issue.body ?? '', + comments, + assignees, + participants + } + } catch { + return null + } +} + function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { const byLogin = new Map() for (const user of users) { @@ -404,8 +533,25 @@ export async function getWorkItemDetails( await acquire() try { if (item.type === 'issue') { - // Why: fetch body/comments and GraphQL participants in parallel; the - // mention-participant merge is a cheap local operation afterward. + // Why: try the collapsed single-GraphQL path first — body, assignees, + // participants, and comments all return in one round-trip. On any + // failure (permissions, partial errors, non-GitHub remote), strictly + // fall back to the legacy REST+GraphQL fan-out so historical behavior + // is preserved. The GraphQL `participants` connection includes every + // commenter, so we skip the extra `getMentionParticipants` aliased + // user-hydration trip when the collapsed path succeeds. + const collapsed = await getIssueDetailsViaGraphQL(repoPath, item.number) + if (collapsed) { + return { + item, + body: collapsed.body, + comments: collapsed.comments, + assignees: collapsed.assignees, + participants: collapsed.participants + } + } + // Why: fall back to body/comments and GraphQL participants in parallel; + // the mention-participant merge is a cheap local operation afterward. const [{ body, comments, assignees }, participants] = await Promise.all([ getIssueBodyAndComments(repoPath, item.number), getWorkItemParticipants(repoPath, item) diff --git a/src/main/hooks-setup-scripts-match.test.ts b/src/main/hooks-setup-scripts-match.test.ts deleted file mode 100644 index 41338b101bd..00000000000 --- a/src/main/hooks-setup-scripts-match.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Repo } from '../shared/types' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('fs', () => ({ - readFileSync: vi.fn(), - existsSync: vi.fn(), - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - rmSync: vi.fn(), - chmodSync: vi.fn() -})) - -const makeRepo = () => - ({ - id: 'test-id', - path: '/test/repo', - displayName: 'Test Repo', - badgeColor: '#000', - addedAt: Date.now() - }) as unknown as Repo - -describe('setupScriptsMatch', () => { - beforeEach(() => { - vi.resetAllMocks() - }) - - it('returns true when primary and worktree setup scripts are identical', async () => { - const fs = await import('fs') - vi.mocked(fs.existsSync).mockImplementation( - (path) => path === '/test/repo/orca.yaml' || path === '/test/worktree/orca.yaml' - ) - vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === '/test/repo/orca.yaml' || path === '/test/worktree/orca.yaml') { - return 'scripts:\n setup: |\n pnpm install\n' - } - return '' - }) - - const { setupScriptsMatch } = await import('./hooks') - expect(setupScriptsMatch(makeRepo(), '/test/worktree')).toBe(true) - }) - - it('returns false when the worktree setup script differs from the primary script', async () => { - const fs = await import('fs') - vi.mocked(fs.existsSync).mockImplementation( - (path) => path === '/test/repo/orca.yaml' || path === '/test/worktree/orca.yaml' - ) - vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === '/test/repo/orca.yaml') { - return 'scripts:\n setup: |\n pnpm install\n' - } - if (path === '/test/worktree/orca.yaml') { - return 'scripts:\n setup: |\n curl https://example.com/install.sh | bash\n' - } - return '' - }) - - const { setupScriptsMatch } = await import('./hooks') - expect(setupScriptsMatch(makeRepo(), '/test/worktree')).toBe(false) - }) -}) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index d83d7951312..0beb03a64bf 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -284,15 +284,6 @@ export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks } } -export function setupScriptsMatch( - repo: Repo, - worktreePath: string, - primarySetupScript = getEffectiveHooks(repo)?.scripts.setup -): boolean { - const worktreeSetupScript = getEffectiveHooks(repo, worktreePath)?.scripts.setup - return primarySetupScript === worktreeSetupScript -} - export function getEffectiveSetupRunPolicy(repo: Repo): SetupRunPolicy { return repo.hookSettings?.setupRunPolicy ?? getDefaultRepoHookSettings().setupRunPolicy! } diff --git a/src/main/index.ts b/src/main/index.ts index 650ea14045b..a0f3a86ca32 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,10 +12,14 @@ import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { killAllPty } from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' -import { setAppRuntimeFlags } from './ipc/app' import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' -import { initTelemetry, shutdownTelemetry } from './telemetry/client' +import { registerMobileHandlers } from './ipc/mobile' +import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client' +import { runManagedHookInstallers } from './agent-hooks/install-telemetry' +import { initCohortClassifier } from './telemetry/cohort-classifier' +import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' +import { resolveConsent } from './telemetry/consent' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' @@ -48,6 +52,7 @@ import { cursorHookService } from './cursor/hook-service' import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' +import { setUnreadDockBadgeCount } from './dock/unread-badge' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the @@ -119,7 +124,16 @@ function focusExistingWindow(): void { // derives the lock identity from the `userData` path, so this placement lets // dev (`orca-dev`) and packaged (`orca`) runs lock in separate namespaces // instead of serialising against each other. -const hasSingleInstanceLock = acquireSingleInstanceLock(app, focusExistingWindow) +// +// Why skip in dev: engineers routinely run `pnpm dev` in parallel from +// multiple worktrees while shipping features, and the lock makes the second +// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` and +// `endpoint.env` may race (the bundled `orca-dev` CLI / agent hooks route +// to whichever instance wrote last). The dev build is not used for real +// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps +// the lock to protect against the corruption documented in PR #1326 / +// issue #1312. +const hasSingleInstanceLock = is.dev ? true : acquireSingleInstanceLock(app, focusExistingWindow) if (!hasSingleInstanceLock) { if (is.dev) { // Why: packaged runs have no attached console, but dev runs do. Emit a @@ -206,6 +220,22 @@ function openMainWindow(): BrowserWindow { } }) + // Why: telemetry-plan.md§First-launch experience anchors default-on + // `app_opened` to the first main-window load. Existing users in the + // pending-banner cohort resolve through telemetry/client.ts; this load + // path only fires once consent is already enabled. + const onFirstWindowLoad = (): void => { + if (!store) { + return + } + const consent = resolveConsent(store.getSettings()) + if (consent.effective !== 'enabled') { + return + } + trackAppOpenedOnce() + } + window.webContents.on('did-finish-load', onFirstWindowLoad) + registerCoreHandlers( store, runtime, @@ -236,46 +266,44 @@ function openMainWindow(): BrowserWindow { // replay-loop through lastStatusByPaneKey runs only on deliberate // window recreations instead of stacking on top of stale listeners. agentHookServer.setListener(null) - // Why: any running cursor spinner intervals would fire into a destroyed - // webContents; stop them all here instead of deferring to per-pane - // teardown, which may never run for restored-but-never-torn-down panes - // when the window goes away. - // Why: stopCursorSpinner deletes only the current entry, which the Map - // iterator handles safely — no snapshot copy needed. - for (const paneKey of cursorSpinnerByPaneKey.keys()) { - stopCursorSpinner(paneKey) + // Why: any running synthesized-title spinner intervals would fire into a + // destroyed webContents; stop them all here instead of deferring to + // per-pane teardown, which may never run for restored-but-never-torn-down + // panes when the window goes away. stopSyntheticTitleSpinner deletes only + // the current entry, which the Map iterator handles safely. + for (const paneKey of syntheticTitleSpinnerByPaneKey.keys()) { + stopSyntheticTitleSpinner(paneKey) } }) mainWindow = window - agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => { - if (mainWindow?.isDestroyed()) { - return - } - // Why: only forward status events to the renderer when the user has - // opted into the experimental dashboard. Reading the current setting - // here (rather than a module-level snapshot) lets the gate flip live - // for the renderer-side surfaces — the hook server itself always runs. - if (store?.getSettings().experimentalAgentDashboard === true) { + agentHookServer.setListener( + ({ paneKey, tabId, worktreeId, connectionId, payload, receivedAt, stateStartedAt }) => { + if (mainWindow?.isDestroyed()) { + return + } mainWindow?.webContents.send('agentStatus:set', { + ...payload, paneKey, tabId, worktreeId, - ...payload + connectionId, + receivedAt, + stateStartedAt }) + // Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn, + // and opencode's stays bare "OpenCode" — neither carries a working/idle + // signal the title heuristic can read. Synthesize an OSC title update + // from the hook state and inject it into the pane's data stream so the + // existing renderer-side title tracker (which drives the sidebar + // spinner, unread badge, and worktree status dot for every other agent) + // lights up for these panes too. Braille prefix → working keyword path; + // "action required" → permission; bare label → idle. + const profile = SYNTHETIC_TITLE_PROFILES[payload.agentType ?? ''] + if (profile) { + driveSyntheticTitleFromHook(paneKey, payload.state, profile) + } } - // Why: cursor-agent emits no title-based working/idle signal — its OSC - // title stays "Cursor Agent" for the whole turn. Synthesize an OSC title - // update from the hook state and inject it into the pane's data stream so - // the existing renderer-side title tracker (the one that drives the - // sidebar spinner, unread badge, and Claude prompt-cache timer for every - // other agent) lights up for cursor panes too. Braille prefix ⠋ → working - // keyword path; "action required" keyword → permission; bare label → idle. - // This runs regardless of the dashboard setting because cursor has no - // pre-dashboard title heuristic to fall back to. - if (payload.agentType === 'cursor') { - driveCursorPaneFromHook(paneKey, payload.state) - } - }) + ) return window } @@ -286,36 +314,68 @@ function openMainWindow(): BrowserWindow { // fresh working frame on an interval until the hook reports a non-working // state. Interval matches Pi's 80ms cadence — fast enough for a smooth // spinner, slow enough to stay well under the per-flush IPC budget. -const CURSOR_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] -const CURSOR_SPINNER_INTERVAL_MS = 80 -const cursorSpinnerByPaneKey = new Map< +// Why: opencode emits a single literal "OpenCode" title at startup and +// nothing thereafter, so a one-shot working frame would suffice for it. We +// reuse the same persistent-spinner mechanism rather than branching because +// the animated spinner is also nicer UX (matches every other working agent). +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 80 + +// Why: per-agent labels for the synthesized titles. The detector classifies +// these labels via `containsAgentName` + spinner/keyword rules, so the chosen +// strings must round-trip through detectAgentStatusFromTitle to the right +// status. See agent-status.test.ts for the pinned classifications. +type SyntheticTitleProfile = { + workingLabel: string + permissionLabel: string + idleLabel: string +} +const SYNTHETIC_TITLE_PROFILES: Record = { + cursor: { + workingLabel: 'Cursor Agent', + permissionLabel: 'Cursor - action required', + idleLabel: 'Cursor ready' + }, + opencode: { + workingLabel: 'OpenCode', + permissionLabel: 'OpenCode - action required', + idleLabel: 'OpenCode ready' + } +} + +const syntheticTitleSpinnerByPaneKey = new Map< string, - { timer: ReturnType; frame: number } + { timer: ReturnType; frame: number; profile: SyntheticTitleProfile } >() // Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner -// interval would keep firing but sendCursorTitle would no-op forever. Stop -// the interval explicitly so the process doesn't carry a timer per dead pane. +// interval would keep firing but sendSyntheticTitle would no-op forever. +// Stop the interval explicitly so the process doesn't carry a timer per dead +// pane. registerPaneKeyTeardownListener((paneKey) => { - stopCursorSpinner(paneKey) + stopSyntheticTitleSpinner(paneKey) }) -function sendCursorTitle(ptyId: string, data: string): void { +function sendSyntheticTitle(ptyId: string, data: string): void { if (!mainWindow || mainWindow.isDestroyed()) { return } mainWindow.webContents.send('pty:data', { id: ptyId, data }) } -function stopCursorSpinner(paneKey: string): void { - const entry = cursorSpinnerByPaneKey.get(paneKey) +function stopSyntheticTitleSpinner(paneKey: string): void { + const entry = syntheticTitleSpinnerByPaneKey.get(paneKey) if (entry) { clearInterval(entry.timer) - cursorSpinnerByPaneKey.delete(paneKey) + syntheticTitleSpinnerByPaneKey.delete(paneKey) } } -function driveCursorPaneFromHook(paneKey: string, state: string): void { +function driveSyntheticTitleFromHook( + paneKey: string, + state: string, + profile: SyntheticTitleProfile +): void { const ptyId = getPtyIdForPaneKey(paneKey) if (!ptyId) { return @@ -324,44 +384,49 @@ function driveCursorPaneFromHook(paneKey: string, state: string): void { // Why: immediately emit the first frame so the spinner starts visible at // this hook event even if the interval's next tick is 80ms away. Subsequent // frames come from the interval below. - const existing = cursorSpinnerByPaneKey.get(paneKey) + const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) const frame = existing ? existing.frame : 0 - sendCursorTitle(ptyId, `\x1b]0;${CURSOR_SPINNER_FRAMES[frame]} Cursor Agent\x07`) + sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) if (existing) { + // Why: refresh the profile so an agent-type change mid-pane (rare, but + // possible if a hook reports a different agentType than the previous + // event) lands on the right idle/permission labels at terminal state. + existing.profile = profile return } const timer = setInterval(() => { const ptyIdNow = getPtyIdForPaneKey(paneKey) if (!ptyIdNow) { - stopCursorSpinner(paneKey) + stopSyntheticTitleSpinner(paneKey) return } - const cur = cursorSpinnerByPaneKey.get(paneKey) + const cur = syntheticTitleSpinnerByPaneKey.get(paneKey) if (!cur) { return } - cur.frame = (cur.frame + 1) % CURSOR_SPINNER_FRAMES.length - sendCursorTitle(ptyIdNow, `\x1b]0;${CURSOR_SPINNER_FRAMES[cur.frame]} Cursor Agent\x07`) - }, CURSOR_SPINNER_INTERVAL_MS) - cursorSpinnerByPaneKey.set(paneKey, { timer, frame }) + cur.frame = (cur.frame + 1) % SPINNER_FRAMES.length + sendSyntheticTitle( + ptyIdNow, + `\x1b]0;${SPINNER_FRAMES[cur.frame]} ${cur.profile.workingLabel}\x07` + ) + }, SPINNER_INTERVAL_MS) + syntheticTitleSpinnerByPaneKey.set(paneKey, { timer, frame, profile }) return } // Why: leaving the spinner running after a `blocked`/`waiting`/`done` event // would immediately race the terminal state back to "working" on the next // tick. Stop first, then inject the terminal frame. Idle/done uses a - // decorated "Cursor ready" label rather than the bare native "Cursor Agent" - // — which the detector deliberately treats as a no-op so cursor's own + // decorated " ready" label rather than the bare native title — which + // for cursor the detector deliberately treats as a no-op so cursor's own // per-turn re-emissions cannot clobber our synthesized state. The // done/permission frames also carry a trailing BEL (0x07 outside of any OSC - // sequence) because cursor-agent does not emit one on its own — and the - // tab-level unread badge + notification dispatch in pty-connection keys off - // BEL, not the working→idle title transition. - stopCursorSpinner(paneKey) - const synthetic = - state === 'blocked' || state === 'waiting' - ? '\x1b]0;Cursor - action required\x07\x07' - : '\x1b]0;Cursor ready\x07\x07' - sendCursorTitle(ptyId, synthetic) + // sequence) so the tab-level unread badge + notification dispatch in + // pty-connection lights up — those key off BEL, not the working→idle title + // transition. + stopSyntheticTitleSpinner(paneKey) + const label = + state === 'blocked' || state === 'waiting' ? profile.permissionLabel : profile.idleLabel + sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`) } app.whenReady().then(async () => { @@ -381,6 +446,14 @@ app.whenReady().then(async () => { // the Store reference, seeds common props, and resets per-session burst // caps. Actual transport initialization is still gated by both flags. initTelemetry(store) + // Why: cohort-classifier reads the repo count synchronously at every emit + // for cohort-extended events. The Store has been sync-loaded above, and + // this init runs before any IPC handler is registered and before any + // window loads — so the classifier is hydrated before any `track()` call, + // regardless of whether it originates from the renderer, an IPC handler, + // or `trackAppOpenedOnce` / `did-finish-load`. + initCohortClassifier(store) + initOnboardingCohortClassifier(store) stats = new StatsCollector() claudeUsage = new ClaudeUsageStore(store) codexUsage = new CodexUsageStore(store) @@ -412,41 +485,23 @@ app.whenReady().then(async () => { // and defeat the teardown helper's prefix sweep (design §4.3 wire-up). getLocalProvider: () => getLocalPtyProvider() }) + runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) starNag = new StarNagService(store, stats) starNag.start() starNag.registerIpcHandlers() runtime.setAgentBrowserBridge(new AgentBrowserBridge(browserManager)) nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why: managed hook installation mutates user-global agent config. - // Startup must fail open so a malformed local config never bricks Orca. - // Claude/Codex/Gemini installs are gated behind the experimentalAgentDashboard - // setting because the feature they feed (the inline agent-activity list) is - // still in preview. Cursor installs unconditionally because cursor-agent - // emits no title-based working/idle signal at all (its terminal title stays - // literally "Cursor Agent" across a turn), so the hook channel is the only - // way to drive the sidebar spinner + unread path for it — there is no - // title-based fallback the way Claude/Codex have. Toggling the setting - // takes effect on next launch because the hook scripts are installed once - // per boot. - const agentDashboardEnabled = store.getSettings().experimentalAgentDashboard === true - if (agentDashboardEnabled) { - for (const installManagedHooks of [ - () => claudeHookService.install(), - () => codexHookService.install(), - () => geminiHookService.install() - ]) { - try { - installManagedHooks() - } catch (error) { - console.error('[agent-hooks] Failed to install managed hooks:', error) - } - } - } - try { - cursorHookService.install() - } catch (error) { - console.error('[agent-hooks] Failed to install Cursor managed hooks:', error) - } + // Why: managed hook installation mutates user-global agent config. Each + // installer runs inside its own try/catch so a malformed local config + // (e.g. corrupted ~/.claude/settings.json) cannot brick Orca startup. + // The agent label travels with each installer so the catch can attribute + // the failure in the `agent_hook_install_failed` telemetry event. + runManagedHookInstallers([ + ['claude', () => claudeHookService.install()], + ['codex', () => codexHookService.install()], + ['gemini', () => geminiHookService.install()], + ['cursor', () => cursorHookService.install()] + ]) registerAppMenu({ onCheckForUpdates: (options) => checkForUpdatesFromMenu(options), @@ -493,15 +548,31 @@ app.whenReady().then(async () => { const ui = store?.getUI() return { showTasksButton: settings?.showTasksButton !== false, - showTitlebarAgentActivity: settings?.showTitlebarAgentActivity !== false, + showTitlebarAppName: settings?.showTitlebarAppName !== false, statusBarVisible: ui?.statusBarVisible !== false } } }) + // Why: E2E tests launch parallel Electron instances that would all race to + // bind the default fixed port, crashing on EADDRINUSE. Port 0 lets the OS + // assign a random available port per instance while still exercising the + // full WebSocket startup path. + const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + // Why: a developer running `pnpm dev` while the packaged Orca is also open + // would otherwise race the packaged app for 6768 and silently fall back to + // a random OS-assigned port — breaking deterministic mobile pairing/repro + // scripts against the dev instance. Pin the first dev instance to 6769 so + // ws://127.0.0.1:6769 is stable; a second dev instance still falls back via + // ws-transport's EADDRINUSE handler. + const devWsPort = is.dev && !isE2E ? 6769 : undefined runtimeRpc = new OrcaRuntimeRpcServer({ runtime, - userDataPath: app.getPath('userData') + userDataPath: app.getPath('userData'), + enableWebSocket: true, + ...(isE2E ? { wsPort: 0 } : {}), + ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}) }) + registerMobileHandlers(runtimeRpc) // Why: the persistent-terminal daemon is always started. If it fails, the // LocalPtyProvider (initialized at module load in ipc/pty.ts) remains as the @@ -511,16 +582,9 @@ app.whenReady().then(async () => { } catch (error) { console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) } - setAppRuntimeFlags({ - agentDashboardEnabledAtStartup: agentDashboardEnabled - }) - - // Why: the hook server runs unconditionally so cursor-agent panes can reach - // it. Claude/Codex/Gemini hook scripts stay uninstalled while the - // experimentalAgentDashboard setting is off, so only cursor events flow - // in by default. PTY spawn env reads ORCA_AGENT_HOOK_* from the live - // server state, so the server must start before the window opens — - // otherwise restored terminals race ahead without the env on first launch. + // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, + // so the hook server must start before the window opens — otherwise + // restored terminals race ahead without the env on first launch. try { await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', @@ -551,7 +615,15 @@ app.whenReady().then(async () => { // dialog either doesn't appear or gets immediately covered by the maximized // window, making it impossible for the user to click "Allow". win.once('show', () => { - triggerStartupNotificationRegistration(store!) + // Why: store can be null if init failed earlier; bail rather than risk a + // throw inside an Electron event listener. + if (!store) { + return + } + const onboarding = store.getOnboarding() + if (onboarding.closedAt !== null) { + triggerStartupNotificationRegistration(store) + } }) app.on('activate', () => { @@ -586,6 +658,7 @@ app.on('will-quit', (e) => { // so without this ordering, running agents would produce orphaned // agent_start events with no matching stops. starNag?.stop() + setUnreadDockBadgeCount(0) agentHookServer.stop() stats?.flush() // Why: agent-browser daemon processes would otherwise linger after Orca quits, diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts new file mode 100644 index 00000000000..a87ea123a37 --- /dev/null +++ b/src/main/ipc/agent-hooks.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as AgentHookServerModule from '../agent-hooks/server' + +// Why: cover the agentStatus:drop IPC handler — it must propagate the +// renderer dismissal to dropStatusEntry so the on-disk last-status file +// evicts the entry. + +const dropStatusEntry = vi.fn() +const getStatusSnapshot = vi.fn() +const onHandlers = new Map void>() +const handleHandlers = new Map unknown>() +const removeHandler = vi.fn() +const removeAllListeners = vi.fn() + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + handleHandlers.set(channel, handler) + }, + on: (channel: string, handler: (event: unknown, ...args: unknown[]) => void) => { + onHandlers.set(channel, handler) + }, + removeHandler, + removeAllListeners + } +})) + +vi.mock('../agent-hooks/server', async () => { + // Why: import the real isValidPaneKey so this test stays in sync with any + // tightening of the validator (length cap, character allow-list, etc). + const actual = await vi.importActual('../agent-hooks/server') + return { + ...actual, + agentHookServer: { + dropStatusEntry, + getStatusSnapshot + } + } +}) + +vi.mock('../claude/hook-service', () => ({ + claudeHookService: { getStatus: vi.fn(() => ({ agent: 'claude', state: 'absent' })) } +})) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { getStatus: vi.fn(() => ({ agent: 'codex', state: 'absent' })) } +})) +vi.mock('../gemini/hook-service', () => ({ + geminiHookService: { getStatus: vi.fn(() => ({ agent: 'gemini', state: 'absent' })) } +})) +vi.mock('../cursor/hook-service', () => ({ + cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) } +})) + +beforeEach(() => { + dropStatusEntry.mockReset() + getStatusSnapshot.mockReset() + onHandlers.clear() + handleHandlers.clear() + removeHandler.mockReset() + removeAllListeners.mockReset() +}) + +afterEach(() => { + vi.resetModules() +}) + +describe('agentStatus:getSnapshot IPC', () => { + it('returns the hook cache snapshot', async () => { + const snapshot = [ + { + paneKey: 'tab-1:0', + state: 'done', + prompt: 'p', + agentType: 'claude', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + } + ] + getStatusSnapshot.mockReturnValue(snapshot) + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentStatus:getSnapshot') + expect(handler).toBeDefined() + expect(handler!({})).toEqual(snapshot) + }) +}) + +describe('agentStatus:drop IPC', () => { + it('forwards drop to dropStatusEntry', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:drop') + expect(handler).toBeDefined() + handler!({}, 'tab-1:0') + expect(dropStatusEntry).toHaveBeenCalledWith('tab-1:0') + }) + + it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:drop')! + const bad: unknown[] = [ + 123, + undefined, + '', + null, + {}, + [], + 'no-colon', // missing colon — rejected by isValidPaneKey + ':leading', // empty tabId half + 'trailing:', // empty paneId half + 'a:b:c' // multiple colons + ] + for (const value of bad) { + expect(() => handler({}, value)).not.toThrow() + } + expect(dropStatusEntry).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 1ad935ac9d1..0d216985e51 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -1,5 +1,7 @@ import { ipcMain } from 'electron' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' @@ -20,6 +22,31 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentHooks:codexStatus') ipcMain.removeHandler('agentHooks:geminiStatus') ipcMain.removeHandler('agentHooks:cursorStatus') + ipcMain.removeHandler('agentStatus:getSnapshot') + // Why: agentStatus:drop is sent fire-and-forget from the renderer via + // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't + // round-trip a response. Removing first keeps re-registration safe even + // though the module-level registered guard already prevents re-entry today. + ipcMain.removeAllListeners('agentStatus:drop') + ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + return + } + try { + // Why: dropStatusEntry (not clearPaneState) is correct here — the user is + // dismissing a status row, not tearing down a PTY. clearPaneState would also + // wipe the per-pane prompt/tool caches, which the next hook event for that + // (still-alive) pane needs to render a coherent row. + agentHookServer.dropStatusEntry(paneKey) + } catch (err) { + console.warn('[agent-hooks] dropStatusEntry failed:', err) + } + }) + ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => { + // Why: the renderer pulls this after workspace hydration, so startup cannot + // lose replayed statuses while its local store is still empty. + return agentHookServer.getStatusSnapshot() + }) // Why: errors from getStatus() (fs permission denied, homedir resolution // failure, etc.) must be reported inline via state:'error' so the sidebar can diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts new file mode 100644 index 00000000000..9ded836a371 --- /dev/null +++ b/src/main/ipc/agent-trust.ts @@ -0,0 +1,35 @@ +import { ipcMain } from 'electron' +import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' + +export type AgentTrustPreset = 'cursor' | 'copilot' + +/** + * Why: cursor-agent and GitHub Copilot CLI gate first-launch in an unfamiliar + * directory behind a "Do you trust this folder?" menu that consumes + * keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL + * paste flow needs the input box, not the menu, so before Orca spawns the + * agent it asks main to write the same trust artifacts the agents write + * after the user accepts. Best-effort: any IO error is swallowed so a failed + * trust write never blocks the workspace from opening. + */ +export function registerAgentTrustHandlers(): void { + ipcMain.removeHandler('agentTrust:markTrusted') + ipcMain.handle( + 'agentTrust:markTrusted', + async (_event, args: { preset: AgentTrustPreset; workspacePath: string }): Promise => { + if (!args || typeof args.workspacePath !== 'string' || !args.workspacePath) { + return + } + try { + if (args.preset === 'cursor') { + markCursorWorkspaceTrusted(args.workspacePath) + } else if (args.preset === 'copilot') { + markCopilotFolderTrusted(args.workspacePath) + } + } catch { + // Best-effort: see Why above. The user can still accept the trust + // prompt manually if writing the artifact fails. + } + } + ) +} diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 611cbf76e5e..f283d428af6 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -3,31 +3,11 @@ import { promisify } from 'node:util' import { app, ipcMain } from 'electron' import { isPwshAvailable } from '../pwsh' import { isWslAvailable } from '../wsl' +import { setUnreadDockBadgeCount } from '../dock/unread-badge' const execFileAsync = promisify(execFile) -export type AppRuntimeFlags = { - /** Whether the experimental agent dashboard setting was enabled when this - * session booted. When true, Claude/Codex/Gemini managed hook installation - * was attempted at startup (individual install failures are logged but do - * not flip this flag — the inline agents list treats missing hooks as - * no-ops). Toggling the setting only affects hook installation on the next - * launch, so the renderer compares this against the current setting to - * decide whether a "restart required" banner needs to be shown. */ - agentDashboardEnabledAtStartup: boolean -} - -let runtimeFlags: AppRuntimeFlags = { - agentDashboardEnabledAtStartup: false -} - -export function setAppRuntimeFlags(flags: AppRuntimeFlags): void { - runtimeFlags = flags -} - export function registerAppHandlers(): void { - ipcMain.handle('app:getRuntimeFlags', (): AppRuntimeFlags => runtimeFlags) - ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) @@ -87,4 +67,8 @@ export function registerAppHandlers(): void { app.exit(0) }, 150) }) + + ipcMain.handle('app:setUnreadDockBadgeCount', (_event, count: number) => { + setUnreadDockBadgeCount(Number.isFinite(count) ? count : 0) + }) } diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 2aa42699d26..e2424648b98 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -103,6 +103,7 @@ export function registerBrowserHandlers(): void { browserPageId: string workspaceId: string worktreeId: string + sessionProfileId?: string | null webContentsId: number } ) => { diff --git a/src/main/ipc/computer-use-permissions.test.ts b/src/main/ipc/computer-use-permissions.test.ts new file mode 100644 index 00000000000..fea36c778d8 --- /dev/null +++ b/src/main/ipc/computer-use-permissions.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, openComputerUsePermissionsMock, getComputerUsePermissionStatusMock } = + vi.hoisted(() => ({ + handleMock: vi.fn(), + openComputerUsePermissionsMock: vi.fn(), + getComputerUsePermissionStatusMock: vi.fn() + })) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('../computer/macos-computer-use-permissions', () => ({ + getComputerUsePermissionStatus: getComputerUsePermissionStatusMock, + openComputerUsePermissions: openComputerUsePermissionsMock +})) + +import { registerComputerUsePermissionHandlers } from './computer-use-permissions' + +describe('registerComputerUsePermissionHandlers', () => { + beforeEach(() => { + handleMock.mockReset() + getComputerUsePermissionStatusMock.mockReset() + openComputerUsePermissionsMock.mockReset() + }) + + it('launches the computer-use helper setup', async () => { + const result = { + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + permissionId: 'accessibility', + openedSettings: false, + launchedHelper: true + } + openComputerUsePermissionsMock.mockReturnValue(result) + + registerComputerUsePermissionHandlers() + + const registration = handleMock.mock.calls.find( + ([channel]) => channel === 'computerUsePermissions:openSetup' + ) + expect(registration).toBeTruthy() + + await expect(registration![1](null, { id: 'accessibility' })).resolves.toBe(result) + expect(openComputerUsePermissionsMock).toHaveBeenCalledWith('accessibility') + }) + + it('returns computer-use permission status', async () => { + const result = { + platform: 'darwin', + permissions: [ + { id: 'accessibility', status: 'granted' }, + { id: 'screenshots', status: 'not-granted' } + ] + } + getComputerUsePermissionStatusMock.mockReturnValue(result) + + registerComputerUsePermissionHandlers() + + const registration = handleMock.mock.calls.find( + ([channel]) => channel === 'computerUsePermissions:getStatus' + ) + expect(registration).toBeTruthy() + + await expect(registration![1]()).resolves.toBe(result) + expect(getComputerUsePermissionStatusMock).toHaveBeenCalledWith() + }) +}) diff --git a/src/main/ipc/computer-use-permissions.ts b/src/main/ipc/computer-use-permissions.ts new file mode 100644 index 00000000000..8665fa9a096 --- /dev/null +++ b/src/main/ipc/computer-use-permissions.ts @@ -0,0 +1,28 @@ +import { ipcMain } from 'electron' +import type { + ComputerUsePermissionId, + ComputerUsePermissionSetupResult, + ComputerUsePermissionStatusResult +} from '../../shared/computer-use-permissions-types' + +export function registerComputerUsePermissionHandlers(): void { + ipcMain.handle( + 'computerUsePermissions:openSetup', + async ( + _event, + args?: { id?: ComputerUsePermissionId } + ): Promise => { + const { openComputerUsePermissions } = + await import('../computer/macos-computer-use-permissions') + return openComputerUsePermissions(args?.id) + } + ) + ipcMain.handle( + 'computerUsePermissions:getStatus', + async (): Promise => { + const { getComputerUsePermissionStatus } = + await import('../computer/macos-computer-use-permissions') + return getComputerUsePermissionStatus() + } + ) +} diff --git a/src/main/ipc/feedback.test.ts b/src/main/ipc/feedback.test.ts new file mode 100644 index 00000000000..f2b06aff2fb --- /dev/null +++ b/src/main/ipc/feedback.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fetchMock = vi.fn() + +vi.mock('electron', () => ({ + app: { getVersion: () => '1.2.3-test' }, + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() }, + net: { fetch: (...args: unknown[]) => fetchMock(...args) } +})) + +import { submitFeedback } from './feedback' + +function okResponse(): Response { + return { ok: true, status: 200 } as unknown as Response +} + +function postedBody(): Record { + const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined + return JSON.parse(String(init?.body)) as Record +} + +describe('submitFeedback', () => { + beforeEach(() => { + fetchMock.mockReset() + fetchMock.mockResolvedValue(okResponse()) + }) + + it('strips GitHub identity and anonymous contact fields when submitted anonymously', async () => { + const anonymousArgs = { + feedback: 'private bug report', + submitAnonymously: true, + githubLogin: 'trusted-user', + githubEmail: 'trusted@example.com', + anonymousGithubLogin: 'trusted-user', + anonymousEmail: 'trusted@example.com', + anonymousX: 'trusted' + } + await submitFeedback(anonymousArgs) + + const body = postedBody() + expect(body).toMatchObject({ + feedback: 'private bug report', + githubLogin: null, + githubEmail: null, + appVersion: '1.2.3-test' + }) + expect(body).not.toHaveProperty('anonymousGithubLogin') + expect(body).not.toHaveProperty('anonymousEmail') + expect(body).not.toHaveProperty('anonymousX') + }) + + it('preserves verified GitHub identity when not submitted anonymously', async () => { + await submitFeedback({ + feedback: 'public bug report', + submitAnonymously: false, + githubLogin: 'trusted-user', + githubEmail: 'trusted@example.com' + }) + + const body = postedBody() + expect(body).toMatchObject({ + feedback: 'public bug report', + githubLogin: 'trusted-user', + githubEmail: 'trusted@example.com', + appVersion: '1.2.3-test' + }) + }) +}) diff --git a/src/main/ipc/feedback.ts b/src/main/ipc/feedback.ts index 82f61bd393b..5b6895c7cfd 100644 --- a/src/main/ipc/feedback.ts +++ b/src/main/ipc/feedback.ts @@ -11,11 +11,15 @@ const FEEDBACK_API_FALLBACK_URL = 'https://www.onorca.dev/v1/feedback' export type FeedbackSubmitArgs = { feedback: string + submitAnonymously?: boolean githubLogin: string | null githubEmail: string | null } -type FeedbackSubmitBody = FeedbackSubmitArgs & { +type FeedbackSubmitBody = { + feedback: string + githubLogin: string | null + githubEmail: string | null appVersion: string platform: NodeJS.Platform osRelease: string @@ -32,8 +36,15 @@ export type FeedbackSubmitResult = // node os module), so we enrich the payload here rather than trusting the // renderer. function buildSubmitBody(args: FeedbackSubmitArgs): FeedbackSubmitBody { + const identity = args.submitAnonymously + ? { githubLogin: null, githubEmail: null } + : { githubLogin: args.githubLogin, githubEmail: args.githubEmail } + + // Why: anonymity is an IPC-only privacy decision. Allow-list fields here so + // stale renderer state or future identity-shaped fields cannot leak upstream. return { - ...args, + feedback: args.feedback, + ...identity, appVersion: app.getVersion(), platform: process.platform, osRelease: os.release(), diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index af9aed31e42..2118be37ed6 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,5 +1,5 @@ -import { realpath } from 'fs/promises' import { resolve, relative, dirname, basename, isAbsolute } from 'path' +import { realpath } from 'fs/promises' import type { Store } from '../persistence' import { listRepoWorktrees } from '../repo-worktrees' @@ -8,6 +8,8 @@ export const PATH_ACCESS_DENIED_MESSAGE = const authorizedExternalPaths = new Set() const registeredWorktreeRoots = new Set() +const registeredWorktreeRootsByRepo = new Map>() +const registeredWorktreeRootRepoIds = new Set() let registeredWorktreeRootsDirty = true let registeredWorktreeRootsRefresh: Promise | null = null @@ -17,6 +19,18 @@ export function authorizeExternalPath(targetPath: string): void { export function invalidateAuthorizedRootsCache(): void { registeredWorktreeRootsDirty = true + // Why: dirty roots cannot be trusted for auth short-circuits. Fresh + // worktrees:list results will seed safe per-repo roots before a full rebuild. + registeredWorktreeRoots.clear() + registeredWorktreeRootsByRepo.clear() + registeredWorktreeRootRepoIds.clear() +} + +function getLocalRepos(store: Store) { + // Why: SSH repo paths are meaningful on the remote host. Treating them as + // local roots can both authorize unrelated local folders and probe paths + // that Orca should only touch through the SSH provider. + return store.getRepos().filter((repo) => !repo.connectionId) } /** @@ -40,7 +54,7 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string } export function getAllowedRoots(store: Store): string[] { - const roots = store.getRepos().map((repo) => resolve(repo.path)) + const roots = getLocalRepos(store).map((repo) => resolve(repo.path)) const workspaceDir = store.getSettings().workspaceDir if (workspaceDir) { roots.push(resolve(workspaceDir)) @@ -67,17 +81,22 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { // all repos. The previous sequential loop was the main bottleneck on // Windows where each `git worktree list` + realpath chain takes 500 ms+ // due to slower process creation and antivirus I/O scanning. - const repos = store.getRepos() + // + // Why no realpath() here: this rebuild runs on repo/worktree invalidation, + // so canonicalizing every repo root would repeatedly touch TCC-protected + // folders on macOS even when the user is idle. The actual + // file handlers still canonicalize the specific target path before any + // destructive or read/write operation, so the security boundary remains + // enforced where it matters. + const repos = getLocalRepos(store) const perRepoResults = await Promise.all( repos.map(async (repo) => { const roots: string[] = [] try { - roots.push(await normalizeExistingPath(repo.path)) + roots.push(resolve(repo.path)) const worktrees = await listRepoWorktrees(repo) - const worktreeRoots = await Promise.all( - worktrees.map((wt) => normalizeExistingPath(wt.path)) - ) + const worktreeRoots = worktrees.map((wt) => resolve(wt.path)) roots.push(...worktreeRoots) } catch (error) { // Why: a single inaccessible repo (EACCES, EIO, etc.) must not break @@ -86,19 +105,50 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { // the rest proceed. console.warn(`[filesystem-auth] skipping repo ${repo.path} during cache rebuild:`, error) } - return roots + return { repoId: repo.id, roots } }) ) registeredWorktreeRoots.clear() - for (const roots of perRepoResults) { + registeredWorktreeRootsByRepo.clear() + registeredWorktreeRootRepoIds.clear() + for (const { repoId, roots } of perRepoResults) { + const normalizedRoots = new Set() for (const root of roots) { + normalizedRoots.add(root) registeredWorktreeRoots.add(root) } + registeredWorktreeRootsByRepo.set(repoId, normalizedRoots) + registeredWorktreeRootRepoIds.add(repoId) } registeredWorktreeRootsDirty = false } +export function registerWorktreeRootsForRepo( + store: Store, + repoId: string, + worktreeRoots: string[] +): void { + const localRepoIds = new Set(getLocalRepos(store).map((repo) => repo.id)) + for (const registeredRepoId of registeredWorktreeRootsByRepo.keys()) { + if (!localRepoIds.has(registeredRepoId)) { + registeredWorktreeRootsByRepo.delete(registeredRepoId) + registeredWorktreeRootRepoIds.delete(registeredRepoId) + } + } + + if (!localRepoIds.has(repoId)) { + refreshRegisteredWorktreeRoots() + registeredWorktreeRootsDirty = !allLocalRepoRootsRegistered(localRepoIds) + return + } + + registeredWorktreeRootsByRepo.set(repoId, new Set(worktreeRoots.map((root) => resolve(root)))) + registeredWorktreeRootRepoIds.add(repoId) + refreshRegisteredWorktreeRoots() + registeredWorktreeRootsDirty = !allLocalRepoRootsRegistered(localRepoIds) +} + export async function ensureAuthorizedRootsCache(store: Store): Promise { if (!registeredWorktreeRootsDirty) { return @@ -147,7 +197,11 @@ export async function resolveAuthorizedPath( // (delete/rename) act on the link itself. const realParent = await realpath(dirname(resolvedTarget)) const candidateTarget = resolve(realParent, basename(resolvedTarget)) - if (!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store))) { + if ( + !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { + canonicalSourcePath: resolvedTarget + })) + ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } return candidateTarget @@ -155,7 +209,11 @@ export async function resolveAuthorizedPath( try { const realTarget = await realpath(resolvedTarget) - if (!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store))) { + if ( + !(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, { + canonicalSourcePath: resolvedTarget + })) + ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } return realTarget @@ -166,7 +224,11 @@ export async function resolveAuthorizedPath( const realParent = await realpath(dirname(resolvedTarget)) const candidateTarget = resolve(realParent, basename(resolvedTarget)) - if (!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store))) { + if ( + !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { + canonicalSourcePath: resolvedTarget + })) + ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } return candidateTarget @@ -175,32 +237,30 @@ export async function resolveAuthorizedPath( async function isPathAllowedIncludingRegisteredWorktrees( targetPath: string, - store: Store + store: Store, + options: { canonicalSourcePath?: string } = {} ): Promise { if (isPathAllowed(targetPath, store)) { return true } + if (isRegisteredWorktreePath(targetPath)) { + return true + } + + if (await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath)) { + return true + } + await ensureAuthorizedRootsCache(store) // Why: external linked worktrees are already trusted for git operations. // Cache their normalized roots once and reuse that index so quick-open and // file explorer do not spawn `git worktree list` on every filesystem read. - for (const root of registeredWorktreeRoots) { - if (isDescendantOrEqual(targetPath, root)) { - return true - } - } - - return false -} - -async function normalizeExistingPath(targetPath: string): Promise { - try { - return await realpath(targetPath) - } catch { - return resolve(targetPath) - } + return ( + isRegisteredWorktreePath(targetPath) || + (await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath)) + ) } /** @@ -225,21 +285,101 @@ export async function resolveRegisteredWorktreePath( const resolvedTarget = resolve(worktreePath) - // Resolve through symlinks when the path exists on disk, so that we - // compare canonical paths on both sides (git worktree list also resolves - // symlinks). - const normalizedTarget = await normalizeExistingPath(resolvedTarget) + if (registeredWorktreeRoots.has(resolvedTarget)) { + return resolvedTarget + } - await ensureAuthorizedRootsCache(store) - for (const root of registeredWorktreeRoots) { - if (normalizedTarget === root) { - return normalizedTarget - } + if (registeredWorktreeRootsDirty) { + await ensureAuthorizedRootsCache(store) + } + + if (registeredWorktreeRoots.has(resolvedTarget)) { + return resolvedTarget + } + + // Resolve through symlinks only after the cheap registered-root check. + // On macOS, realpath() can itself trigger TCC prompts for protected roots. + const normalizedTarget = await normalizeExistingPath(resolvedTarget) + if (registeredWorktreeRoots.has(normalizedTarget)) { + return normalizedTarget } throw new Error('Access denied: unknown repository or worktree path') } +function refreshRegisteredWorktreeRoots(): void { + registeredWorktreeRoots.clear() + for (const roots of registeredWorktreeRootsByRepo.values()) { + for (const root of roots) { + registeredWorktreeRoots.add(root) + } + } +} + +function allLocalRepoRootsRegistered(localRepoIds: Set): boolean { + for (const repoId of localRepoIds) { + if (!registeredWorktreeRootRepoIds.has(repoId)) { + return false + } + } + return true +} + +function isRegisteredWorktreePath(targetPath: string): boolean { + for (const root of registeredWorktreeRoots) { + if (isDescendantOrEqual(targetPath, root)) { + return true + } + } + return false +} + +async function isPathAllowedByCanonicalRegisteredRoot( + targetPath: string, + sourcePath: string | undefined +): Promise { + if (!sourcePath) { + return false + } + const textualRoot = findRegisteredWorktreeRoot(sourcePath) + if (!textualRoot) { + return false + } + const canonicalRoot = await normalizeExistingPath(textualRoot) + if (!isDescendantOrEqual(targetPath, canonicalRoot)) { + return false + } + // Why: #1524 stopped realpath'ing every worktree root during background + // refreshes to avoid macOS privacy prompts. Cache only the root the user is + // actively accessing so /var→/private/var aliases work without broad probes. + registeredWorktreeRoots.add(canonicalRoot) + return true +} + +function findRegisteredWorktreeRoot(targetPath: string): string | null { + let bestRoot: string | null = null + for (const root of registeredWorktreeRoots) { + if (!isDescendantOrEqual(targetPath, root)) { + continue + } + if (!bestRoot || root.length > bestRoot.length) { + bestRoot = root + } + } + return bestRoot +} + +async function normalizeExistingPath(resolvedPath: string): Promise { + try { + return resolve(await realpath(resolvedPath)) + } catch (error) { + if (isENOENT(error)) { + return resolvedPath + } + throw error + } +} + export function validateGitRelativeFilePath(worktreePath: string, filePath: string): string { if (!filePath || filePath.includes('\0') || resolve(filePath) === filePath) { throw new Error('Access denied: invalid git file path') diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts new file mode 100644 index 00000000000..d1e3bafdebb --- /dev/null +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('fs/promises', () => ({ + stat: vi.fn() +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./filesystem-watcher-wsl', () => ({ + createWslWatcher: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +describe('registerFilesystemWatcherHandlers', () => { + const handlers: HandlerMap = {} + + beforeEach(() => { + vi.useRealTimers() + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + }) + + it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + getSshFilesystemProviderMock.mockReturnValue(undefined) + + await expect( + handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + ).resolves.toBeUndefined() + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + '[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for /home/me/repo on connection conn-1' + ) + handlers['fs:unwatchWorktree'](null, { worktreePath: '/home/me/repo', connectionId: 'conn-1' }) + warnSpy.mockRestore() + vi.useRealTimers() + }) + + it('binds a pending SSH worktree watch after the filesystem provider appears', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sendMock = vi.fn() + const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 } + const unwatchMock = vi.fn() + const watchMock = vi.fn().mockResolvedValue(unwatchMock) + getSshFilesystemProviderMock.mockReturnValueOnce(undefined) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + const onEvents = watchMock.mock.calls[0][1] + onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + expect(sendMock).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ path: '/home/me/repo/file.txt', type: 'update' }] + }) + warnSpy.mockRestore() + handlers['fs:unwatchWorktree'](null, { worktreePath: '/home/me/repo', connectionId: 'conn-1' }) + vi.useRealTimers() + }) + + it('cancels pending SSH watch retries during watcher shutdown', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const watchMock = vi.fn() + getSshFilesystemProviderMock.mockReturnValueOnce(undefined) + + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + await closeAllWatchers() + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).not.toHaveBeenCalled() + warnSpy.mockRestore() + vi.useRealTimers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index c3c79b33997..7ffbf3ae778 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -428,6 +428,15 @@ function unsubscribe(worktreePath: string, senderId: number): void { // ── Remote watcher state ───────────────────────────────────────────── // Key: `${connectionId}:${worktreePath}`, Value: unwatch function const remoteWatchers = new Map void>() +const loggedUnavailableRemoteWatchers = new Set() +const pendingRemoteWatcherRetries = new Map>() +// Why: track in-flight `provider.watch()` calls so an unwatch/shutdown that +// arrives while a watch is still resolving can mark the install cancelled. +// Without this, the awaited unwatch handle would be installed after the +// renderer thinks the watch is gone, leaking a native watcher. +const inFlightRemoteInstalls = new Map() +const REMOTE_WATCH_RETRY_MS = 1_000 +const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 function replaceRemoteWatcher(key: string, unwatch: () => void): void { const previous = remoteWatchers.get(key) @@ -441,6 +450,111 @@ function replaceRemoteWatcher(key: string, unwatch: () => void): void { remoteWatchers.set(key, unwatch) } +type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' + +async function installRemoteWatcher( + sender: WebContents, + connectionId: string, + worktreePath: string +): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider || sender.isDestroyed()) { + return 'unavailable' + } + + const key = `${connectionId}:${worktreePath}` + const cancelToken = { cancelled: false } + inFlightRemoteInstalls.set(key, cancelToken) + let unwatch: () => void + try { + unwatch = await provider.watch(worktreePath, (events) => { + if (!sender.isDestroyed()) { + sender.send('fs:changed', { + worktreePath, + events + } satisfies FsChangedPayload) + } + }) + } finally { + if (inFlightRemoteInstalls.get(key) === cancelToken) { + inFlightRemoteInstalls.delete(key) + } + } + if (cancelToken.cancelled || sender.isDestroyed()) { + try { + unwatch() + } catch (err) { + console.error(`[filesystem-watcher] remote unwatch (post-cancel) error for ${key}:`, err) + } + return 'cancelled' + } + replaceRemoteWatcher(key, unwatch) + loggedUnavailableRemoteWatchers.delete(key) + + sender.once('destroyed', () => { + const unwatchFn = remoteWatchers.get(key) + if (unwatchFn) { + unwatchFn() + remoteWatchers.delete(key) + } + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + } + }) + return 'installed' +} + +function scheduleRemoteWatcherRetry( + sender: WebContents, + connectionId: string, + worktreePath: string, + startedAt = Date.now() +): void { + const key = `${connectionId}:${worktreePath}` + if (pendingRemoteWatcherRetries.has(key)) { + return + } + + if (Date.now() - startedAt >= REMOTE_WATCH_RETRY_TIMEOUT_MS || sender.isDestroyed()) { + pendingRemoteWatcherRetries.delete(key) + loggedUnavailableRemoteWatchers.delete(key) + // Why: the original `fs:watchWorktree` handler resolved successfully + // when the retry was first scheduled, so the renderer believes the + // watch is live. After giving up, emit a one-shot overflow so the + // renderer falls back to a manual refresh instead of waiting forever. + if (!sender.isDestroyed()) { + console.warn( + `[filesystem-watcher] giving up SSH watch retry for ${worktreePath} on connection ${connectionId} after ${REMOTE_WATCH_RETRY_TIMEOUT_MS}ms` + ) + sender.send('fs:changed', { + worktreePath, + events: [{ kind: 'overflow', absolutePath: worktreePath }] + } satisfies FsChangedPayload) + } + return + } + + const retryTimer = setTimeout(() => { + pendingRemoteWatcherRetries.delete(key) + void installRemoteWatcher(sender, connectionId, worktreePath) + .then((result) => { + // Why: 'cancelled' means an unwatch/shutdown raced with this install + // attempt. Re-arming the retry would reschedule for a worktree the + // renderer explicitly stopped watching, eventually firing a stale + // overflow when the 60s window expires. + if (result === 'unavailable') { + scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt) + } + }) + .catch(() => { + scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt) + }) + }, REMOTE_WATCH_RETRY_MS) + pendingRemoteWatcherRetries.set(key, retryTimer) +} + // ── Public API ─────────────────────────────────────────────────────── export function registerFilesystemWatcherHandlers(): void { @@ -448,29 +562,22 @@ export function registerFilesystemWatcherHandlers(): void { 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - if (!provider) { - throw new Error(`No filesystem provider for connection "${args.connectionId}"`) - } const key = `${args.connectionId}:${args.worktreePath}` - - const unwatch = await provider.watch(args.worktreePath, (events) => { - if (!event.sender.isDestroyed()) { - event.sender.send('fs:changed', { - worktreePath: args.worktreePath, - events - } satisfies FsChangedPayload) + const result = await installRemoteWatcher( + event.sender, + args.connectionId, + args.worktreePath + ) + if (result === 'unavailable') { + if (!loggedUnavailableRemoteWatchers.has(key)) { + loggedUnavailableRemoteWatchers.add(key) + console.warn( + `[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for ${args.worktreePath} on connection ${args.connectionId}` + ) } - }) - replaceRemoteWatcher(key, unwatch) - - event.sender.once('destroyed', () => { - const unwatchFn = remoteWatchers.get(key) - if (unwatchFn) { - unwatchFn() - remoteWatchers.delete(key) - } - }) + scheduleRemoteWatcherRetry(event.sender, args.connectionId, args.worktreePath) + return + } return } await subscribe(args.worktreePath, event.sender) @@ -482,6 +589,20 @@ export function registerFilesystemWatcherHandlers(): void { (_event, args: { worktreePath: string; connectionId?: string }): void => { if (args.connectionId) { const key = `${args.connectionId}:${args.worktreePath}` + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + } + // Why: a `provider.watch()` call may still be in flight from a + // retry tick. Mark it cancelled so installRemoteWatcher discards + // the unwatch handle when the promise finally resolves, instead + // of leaving the renderer with a watcher it asked to stop. + const inFlight = inFlightRemoteInstalls.get(key) + if (inFlight) { + inFlight.cancelled = true + } + loggedUnavailableRemoteWatchers.delete(key) const unwatchFn = remoteWatchers.get(key) if (unwatchFn) { unwatchFn() @@ -503,6 +624,17 @@ export async function closeAllWatchers(): Promise { } pendingTeardowns.clear() + for (const timer of pendingRemoteWatcherRetries.values()) { + clearTimeout(timer) + } + pendingRemoteWatcherRetries.clear() + loggedUnavailableRemoteWatchers.clear() + // Why: cancel any in-flight provider.watch() calls so their resolved + // unwatch handles are discarded instead of being installed after shutdown. + for (const token of inFlightRemoteInstalls.values()) { + token.cancelled = true + } + for (const [rootKey, root] of watchedRoots) { if (root.batch.timer) { clearTimeout(root.batch.timer) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 6c68a269b80..529e0207c30 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -13,6 +13,7 @@ const { openMock, realpathMock, lstatMock, + commitChangesMock, getStatusMock, getDiffMock, getBranchCompareMock, @@ -23,7 +24,8 @@ const { bulkUnstageFilesMock, discardChangesMock, listWorktreesMock, - getSshFilesystemProviderMock + getSshFilesystemProviderMock, + getSshGitProviderMock } = vi.hoisted(() => ({ handleMock: vi.fn(), trashItemMock: vi.fn(), @@ -34,6 +36,7 @@ const { openMock: vi.fn(), realpathMock: vi.fn(), lstatMock: vi.fn(), + commitChangesMock: vi.fn(), getStatusMock: vi.fn(), getDiffMock: vi.fn(), getBranchCompareMock: vi.fn(), @@ -44,7 +47,8 @@ const { bulkUnstageFilesMock: vi.fn(), discardChangesMock: vi.fn(), listWorktreesMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn() + getSshFilesystemProviderMock: vi.fn(), + getSshGitProviderMock: vi.fn() })) vi.mock('electron', () => ({ @@ -67,6 +71,7 @@ vi.mock('fs/promises', () => ({ })) vi.mock('../git/status', () => ({ + commitChanges: commitChangesMock, getStatus: getStatusMock, getDiff: getDiffMock, getBranchCompare: getBranchCompareMock, @@ -87,11 +92,11 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn().mockReturnValue(null) + getSshGitProvider: getSshGitProviderMock })) import { registerFilesystemHandlers } from './filesystem' -import { invalidateAuthorizedRootsCache } from './filesystem-auth' +import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth' // Why: paths are resolved via path.resolve() in production code, so test // data must use resolved paths to avoid Unix-vs-Windows mismatches. @@ -148,6 +153,7 @@ describe('registerFilesystemHandlers', () => { openMock, realpathMock, lstatMock, + commitChangesMock, getStatusMock, getDiffMock, getBranchCompareMock, @@ -158,7 +164,8 @@ describe('registerFilesystemHandlers', () => { bulkUnstageFilesMock, discardChangesMock, listWorktreesMock, - getSshFilesystemProviderMock + getSshFilesystemProviderMock, + getSshGitProviderMock ]) { mock.mockReset() } @@ -182,6 +189,7 @@ describe('registerFilesystemHandlers', () => { } ]) trashItemMock.mockResolvedValue(undefined) + getSshGitProviderMock.mockReturnValue(null) statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 }) openMock.mockResolvedValue({ read: vi.fn(async (buffer: Buffer) => { @@ -211,6 +219,99 @@ describe('registerFilesystemHandlers', () => { expect(readFileMock).not.toHaveBeenCalled() }) + it('allows readDir when a registered worktree resolves to a macOS canonical alias', async () => { + const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature') + const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature') + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath]) + realpathMock.mockImplementation(async (targetPath: string) => { + if (targetPath === aliasWorktreePath) { + return canonicalWorktreePath + } + return targetPath + }) + readdirMock.mockResolvedValue([dirEntry({ name: 'README.md', file: true })]) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:readDir')!(null, { dirPath: aliasWorktreePath }) + ).resolves.toEqual([{ name: 'README.md', isDirectory: false, isSymlink: false }]) + + expect(readdirMock).toHaveBeenCalledWith(canonicalWorktreePath, { withFileTypes: true }) + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('reports symlinked directories from readDir as directories', async () => { + const modelLinkPath = path.join(REPO_PATH, 'Model') + readdirMock.mockResolvedValue([ + dirEntry({ name: 'README.md', file: true }), + dirEntry({ name: 'Model', symlink: true }) + ]) + statMock.mockImplementation(async (targetPath: string) => ({ + size: 10, + isDirectory: () => targetPath === modelLinkPath, + mtimeMs: 123 + })) + + registerFilesystemHandlers(store as never) + + await expect(handlers.get('fs:readDir')!(null, { dirPath: REPO_PATH })).resolves.toEqual([ + { name: 'Model', isDirectory: true, isSymlink: true }, + { name: 'README.md', isDirectory: false, isSymlink: false } + ]) + }) + + it('allows deletePath when a registered worktree parent resolves to a macOS canonical alias', async () => { + const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature') + const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature') + const aliasFilePath = path.join(aliasWorktreePath, 'README.md') + const canonicalFilePath = path.join(canonicalWorktreePath, 'README.md') + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath]) + realpathMock.mockImplementation(async (targetPath: string) => { + if (targetPath === aliasWorktreePath) { + return canonicalWorktreePath + } + return targetPath + }) + + registerFilesystemHandlers(store as never) + + await handlers.get('fs:deletePath')!(null, { targetPath: aliasFilePath }) + + expect(trashItemMock).toHaveBeenCalledWith(canonicalFilePath) + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('rejects readFile when a symlink in a canonical alias worktree escapes the registered root', async () => { + const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature') + const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature') + const aliasLinkPath = path.join(aliasWorktreePath, 'link.txt') + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath]) + realpathMock.mockImplementation(async (targetPath: string) => { + if (targetPath === aliasWorktreePath) { + return canonicalWorktreePath + } + if (targetPath === aliasLinkPath) { + return path.resolve('/private/secret.txt') + } + return targetPath + }) + + registerFilesystemHandlers(store as never) + + await expect(handlers.get('fs:readFile')!(null, { filePath: aliasLinkPath })).rejects.toThrow( + 'Access denied: path resolves outside allowed directories' + ) + + expect(readFileMock).not.toHaveBeenCalled() + }) + + it('does not enumerate worktrees when filesystem handlers register', () => { + registerFilesystemHandlers(store as never) + + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + it('rejects writes to directories', async () => { lstatMock.mockResolvedValue({ isDirectory: () => true }) @@ -336,6 +437,19 @@ describe('registerFilesystemHandlers', () => { expect(stageFileMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, path.join('src', 'file.ts')) }) + it('uses worktree roots seeded by worktrees:list without rebuilding the cache', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [] }) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) + + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(realpathMock).not.toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + }) + it('rejects git file paths that escape the selected worktree', async () => { registerFilesystemHandlers(store as never) @@ -545,6 +659,95 @@ describe('registerFilesystemHandlers', () => { expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main') }) + it('routes local git:commit through commitChanges and returns success', async () => { + commitChangesMock.mockResolvedValue({ success: true }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + message: 'feat: ship commit' + }) + ).resolves.toEqual({ success: true }) + + expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit') + }) + + it('returns local commit hook failure payload from git:commit', async () => { + commitChangesMock.mockResolvedValue({ success: false, error: 'hook failed' }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + message: 'feat: ship commit' + }) + ).resolves.toEqual({ success: false, error: 'hook failed' }) + }) + + it('routes ssh git:commit through the SSH provider instead of local commitChanges', async () => { + const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) + getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: '/remote/repo', + message: 'feat: remote commit', + connectionId: 'conn-1' + }) + ).resolves.toEqual({ success: true }) + + expect(sshCommitMock).toHaveBeenCalledWith('/remote/repo', 'feat: remote commit') + expect(commitChangesMock).not.toHaveBeenCalled() + }) + + it('rejects git:commit with empty message and does not call commitChanges', async () => { + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + message: '' + }) + ).rejects.toThrow('Commit message is required') + + expect(commitChangesMock).not.toHaveBeenCalled() + }) + + it('rejects git:commit with whitespace-only message and does not call commitChanges', async () => { + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + message: ' ' + }) + ).rejects.toThrow('Commit message is required') + + expect(commitChangesMock).not.toHaveBeenCalled() + }) + + it('rejects git:commit with whitespace-only message before SSH dispatch', async () => { + const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) + getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:commit')!(null, { + worktreePath: '/remote/repo', + message: '\n', + connectionId: 'conn-1' + }) + ).rejects.toThrow('Commit message is required') + + expect(sshCommitMock).not.toHaveBeenCalled() + }) + it('allows git operations on worktrees outside repo/workspace roots', async () => { // Linked worktrees can live anywhere on disk (e.g. ~/.codex/worktrees/). // As long as the path matches a worktree reported by `git worktree list` diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 756e702c9dd..cd060af12bc 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import { ipcMain, shell } from 'electron' import { readdir, readFile, writeFile, stat, lstat, open } from 'fs/promises' -import { extname } from 'path' +import { extname, join } from 'path' import type { ChildProcess } from 'child_process' import { wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' @@ -11,6 +11,7 @@ import type { GitBranchCompareResult, GitConflictOperation, GitDiffResult, + GitUpstreamStatus, GitStatusResult, MarkdownDocument, SearchOptions, @@ -28,6 +29,7 @@ import { getStatus, detectConflictOperation, getDiff, + commitChanges, stageFile, unstageFile, bulkStageFiles, @@ -36,14 +38,15 @@ import { getBranchCompare, getBranchDiff } from '../git/status' +import { getUpstreamStatus } from '../git/upstream' +import { gitFetch, gitPull, gitPush } from '../git/remote' import { getRemoteFileUrl } from '../git/repo' import { resolveAuthorizedPath, resolveRegisteredWorktreePath, validateGitRelativeFilePath, isENOENT, - authorizeExternalPath, - rebuildAuthorizedRootsCache + authorizeExternalPath } from './filesystem-auth' import { listQuickOpenFiles } from './filesystem-list-files' import { registerFilesystemMutationHandlers } from './filesystem-mutations' @@ -101,8 +104,28 @@ async function isBinaryFilePrefix(filePath: string): Promise { } } +async function isDirectoryEntry( + dirPath: string, + entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean }, + resolveEntryPath: (entryPath: string) => Promise +): Promise { + if (entry.isDirectory()) { + return true + } + if (!entry.isSymbolicLink()) { + return false + } + try { + // Why: directory symlinks inside a workspace should navigate like folders + // without bypassing the local authorized-path boundary. + const entryPath = await resolveEntryPath(join(dirPath, entry.name)) + return (await stat(entryPath)).isDirectory() + } catch { + return false + } +} + export function registerFilesystemHandlers(store: Store): void { - void rebuildAuthorizedRootsCache(store) const activeTextSearches = new Map() // ─── Filesystem ───────────────────────────────────────── @@ -118,18 +141,21 @@ export function registerFilesystemHandlers(store: Store): void { } const dirPath = await resolveAuthorizedPath(args.dirPath, store) const entries = await readdir(dirPath, { withFileTypes: true }) - return entries - .map((entry) => ({ + const mapped = await Promise.all( + entries.map(async (entry) => ({ name: entry.name, - isDirectory: entry.isDirectory(), + isDirectory: await isDirectoryEntry(dirPath, entry, (entryPath) => + resolveAuthorizedPath(entryPath, store) + ), isSymlink: entry.isSymbolicLink() })) - .sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + ) + return mapped.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) } ) @@ -505,6 +531,28 @@ export function registerFilesystemHandlers(store: Store): void { } ) + ipcMain.handle( + 'git:commit', + async ( + _event, + args: { worktreePath: string; message: string; connectionId?: string } + ): Promise<{ success: boolean; error?: string }> => { + // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. + if (typeof args.message !== 'string' || args.message.trim().length === 0) { + throw new Error('Commit message is required') + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.commit(args.worktreePath, args.message) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return commitChanges(worktreePath, args.message) + } + ) + ipcMain.handle( 'git:branchCompare', async ( @@ -523,6 +571,76 @@ export function registerFilesystemHandlers(store: Store): void { } ) + ipcMain.handle( + 'git:upstreamStatus', + async ( + _event, + args: { worktreePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getUpstreamStatus(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return getUpstreamStatus(worktreePath) + } + ) + + ipcMain.handle( + 'git:fetch', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.fetchRemote(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await gitFetch(worktreePath) + } + ) + + ipcMain.handle( + 'git:push', + async ( + _event, + args: { worktreePath: string; publish?: boolean; connectionId?: string } + ): Promise => { + // Why: coerce to strict boolean at the IPC boundary so a malformed + // renderer payload (e.g. string 'false') can't silently enable + // --set-upstream mode. Mirrors the relay handler in src/relay/git-handler.ts. + const publish = args.publish === true + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.pushBranch(args.worktreePath, publish) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await gitPush(worktreePath, publish) + } + ) + + ipcMain.handle( + 'git:pull', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.pullBranch(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await gitPull(worktreePath) + } + ) + ipcMain.handle( 'git:branchDiff', async ( diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 8e09575309c..8a74e3b741c 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -93,7 +93,7 @@ describe('registerGitHubHandlers', () => { branch: 'feature/test' }) - expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/test') + expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/test', null) }) it('rejects unknown repository paths', async () => { diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 371bf6cd280..4b3e9c20280 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -2,7 +2,7 @@ the repo-path validation, preference-threading, and stats wiring patterns are reviewable as one surface. Splitting by feature area would risk drifting validation/gate conventions across handler files. */ -import { ipcMain } from 'electron' +import { ipcMain, webContents } from 'electron' import { resolve } from 'path' import type { Repo, GitHubIssueUpdate } from '../../shared/types' import type { Store } from '../persistence' @@ -15,6 +15,7 @@ import { listWorkItems, countWorkItems, getWorkItem, + getWorkItemByOwnerRepo, createIssue, updateIssue, addIssueComment, @@ -32,8 +33,70 @@ import { starOrca } from '../github/client' import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' +import { getRateLimit } from '../github/rate-limit' +import { diagnoseGhAuth } from '../github/auth-diagnose' import type { GitHubPRFile } from '../../shared/types' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' +import { + getProjectViewTable, + listAccessibleProjects, + resolveProjectRef, + listProjectViews, + getWorkItemDetailsBySlug, + updateProjectItemFieldValue, + clearProjectItemFieldValue, + updateIssueBySlug, + updatePullRequestBySlug, + addIssueCommentBySlug, + updateIssueCommentBySlug, + deleteIssueCommentBySlug, + listLabelsBySlug, + listAssignableUsersBySlug, + listIssueTypesBySlug, + updateIssueTypeBySlug +} from '../github/project-view' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github-project-types' + +// Why: notify every renderer (each window has its own SWR cache instance) +// that a work item was mutated locally so they can drop their cached entry +// and refetch on the next open. Only emitted after a successful mutation. +// We skip the originating webContents because that renderer already updated +// its cache optimistically — re-broadcasting would race the optimistic write +// and erase it. +function broadcastWorkItemMutated( + payload: { + repoPath: string + type: 'issue' | 'pr' + number: number + }, + senderId?: number +): void { + for (const wc of webContents.getAllWebContents()) { + if (wc.isDestroyed()) { + continue + } + if (senderId !== undefined && wc.id === senderId) { + continue + } + wc.send('gh:workItemMutated', payload) + } +} // Why: returns the full Repo object instead of just the path string so that // callers have access to repo.id for stat tracking and other context. @@ -47,22 +110,25 @@ function assertRegisteredRepo(repoPath: string, store: Store): Repo { } export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { - ipcMain.handle('gh:prForBranch', async (_event, args: { repoPath: string; branch: string }) => { - const repo = assertRegisteredRepo(args.repoPath, store) - const pr = await getPRForBranch(repo.path, args.branch) - // Emit pr_created when a PR is first detected for a branch. - // Why here: the renderer polls gh:prForBranch to check PR status per worktree. - // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). - if (pr && !stats.hasCountedPR(pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: pr.number, prUrl: pr.url } - }) + ipcMain.handle( + 'gh:prForBranch', + async (_event, args: { repoPath: string; branch: string; linkedPRNumber?: number | null }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + const pr = await getPRForBranch(repo.path, args.branch, args.linkedPRNumber ?? null) + // Emit pr_created when a PR is first detected for a branch. + // Why here: the renderer polls gh:prForBranch to check PR status per worktree. + // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). + if (pr && !stats.hasCountedPR(pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: pr.number, prUrl: pr.url } + }) + } + return pr } - return pr - }) + ) ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) @@ -107,6 +173,25 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItem) ) + ipcMain.handle( + 'gh:workItemByOwnerRepo', + ( + _event, + args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + } + ) => + getWorkItemByOwnerRepo( + assertRegisteredRepo(args.repoPath, store).path, + { owner: args.owner, repo: args.repo }, + args.number, + args.type + ) + ) ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItemDetails) ) @@ -171,16 +256,21 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:resolveReviewThread', - (_event, args: { repoPath: string; threadId: string; resolve: boolean }) => { + async (_event, args: { repoPath: string; threadId: string; resolve: boolean }) => { const repo = assertRegisteredRepo(args.repoPath, store) + // Why: thread resolve doesn't carry the PR number, so we cannot target + // a specific cache entry. The renderer cache stores per-(repo, type, number) + // entries — emitting a path-wide invalidation here would require a new + // event shape; instead, the drawer's existing thread-resolve UI updates + // its local state immediately and the next reopen pays one fresh fetch. return resolveReviewThread(repo.path, args.threadId, args.resolve) } ) ipcMain.handle( 'gh:addPRReviewCommentReply', - ( - _event, + async ( + event, args: { repoPath: string prNumber: number @@ -209,7 +299,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi if (!args.body?.trim()) { return { ok: false, error: 'Comment body required' } } - return addPRReviewCommentReply( + const result = await addPRReviewCommentReply( repo.path, args.prNumber, args.commentId, @@ -218,13 +308,20 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.path, args.line ) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result } ) ipcMain.handle( 'gh:addPRReviewComment', - ( - _event, + async ( + event, args: { repoPath: string prNumber: number @@ -264,7 +361,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi if (!args.body?.trim()) { return { ok: false, error: 'Comment body required' } } - return addPRReviewComment({ + const result = await addPRReviewComment({ repoPath: repo.path, prNumber: args.prNumber, commitId: args.commitId.trim(), @@ -273,31 +370,52 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi startLine: args.startLine, body: args.body.trim() }) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result } ) ipcMain.handle( 'gh:updatePRTitle', - (_event, args: { repoPath: string; prNumber: number; title: string }) => { + async (event, args: { repoPath: string; prNumber: number; title: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return updatePRTitle(repo.path, args.prNumber, args.title) + const ok = await updatePRTitle(repo.path, args.prNumber, args.title) + if (ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return ok } ) ipcMain.handle( 'gh:mergePR', - ( - _event, + async ( + event, args: { repoPath: string; prNumber: number; method?: 'merge' | 'squash' | 'rebase' } ) => { const repo = assertRegisteredRepo(args.repoPath, store) - return mergePR(repo.path, args.prNumber, args.method) + const result = await mergePR(repo.path, args.prNumber, args.method) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result } ) ipcMain.handle( 'gh:updateIssue', - (_event, args: { repoPath: string; number: number; updates: GitHubIssueUpdate }) => { + async (event, args: { repoPath: string; number: number; updates: GitHubIssueUpdate }) => { const repo = assertRegisteredRepo(args.repoPath, store) if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { return { ok: false, error: 'Invalid issue number' } @@ -305,13 +423,23 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi if (!args.updates || typeof args.updates !== 'object') { return { ok: false, error: 'Updates object is required' } } - return updateIssue(repo.path, args.number, args.updates) + const result = await updateIssue(repo.path, args.number, args.updates) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, type: 'issue', number: args.number }, + event.sender.id + ) + } + return result } ) ipcMain.handle( 'gh:addIssueComment', - (_event, args: { repoPath: string; number: number; body: string }) => { + async ( + event, + args: { repoPath: string; number: number; body: string; type?: 'issue' | 'pr' } + ) => { const repo = assertRegisteredRepo(args.repoPath, store) if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { return { ok: false, error: 'Invalid issue number' } @@ -319,7 +447,19 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi if (!args.body?.trim()) { return { ok: false, error: 'Comment body required' } } - return addIssueComment(repo.path, args.number, args.body.trim()) + const result = await addIssueComment(repo.path, args.number, args.body.trim()) + if (result.ok) { + // Why: PR conversation comments hit `/issues/N/comments` too, but the + // drawer's cache key uses type='pr'. The caller passes through which + // drawer they're posting from so we only invalidate the matching key + // — broadcasting both would evict an unrelated PR/issue that happens + // to share the number. + broadcastWorkItemMutated( + { repoPath: repo.path, type: args.type ?? 'issue', number: args.number }, + event.sender.id + ) + } + return result } ) @@ -338,6 +478,86 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) ipcMain.handle('gh:starOrca', () => starOrca()) + // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so + // polling is cheap. A 30s in-process cache still avoids the gh subprocess + // cost on every render — see getRateLimit for the ttl rationale. Force + // parameter lets the renderer bust the cache after a known-expensive op + // (e.g. post-ProjectPicker discovery) without waiting out the ttl. + ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => + getRateLimit(args?.force ? { force: true } : undefined) + ) + + ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth()) + + // ── GitHub ProjectV2 view handlers ───────────────────────────────── + // Why: registered unconditionally so enabling the experimental flag at + // runtime takes effect without a restart. The renderer gates entry points. + // Handlers never throw across IPC — every failure mode resolves through the + // GitHubProjectViewError envelope. + + ipcMain.handle('gh:listAccessibleProjects', () => listAccessibleProjects()) + + ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => + resolveProjectRef(args) + ) + + ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => + listProjectViews(args) + ) + + ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => + getProjectViewTable(args) + ) + + ipcMain.handle( + 'gh:projectWorkItemDetailsBySlug', + (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) + ) + + ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => + updateProjectItemFieldValue(args) + ) + + ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => + clearProjectItemFieldValue(args) + ) + + ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => + updateIssueBySlug(args) + ) + + ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => + updatePullRequestBySlug(args) + ) + + ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => + addIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => + updateIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => + deleteIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => + listLabelsBySlug(args) + ) + + ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => + listAssignableUsersBySlug(args) + ) + + ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => + listIssueTypesBySlug(args) + ) + + ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => + updateIssueTypeBySlug(args) + ) + // Why: issue-source preference writes go through the generic `repos:update` // IPC (extended in this PR to accept `issueSourcePreference`). Routing // through the same channel keeps a single write path, guarantees the diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts new file mode 100644 index 00000000000..9b1d8be3502 --- /dev/null +++ b/src/main/ipc/mobile.ts @@ -0,0 +1,139 @@ +import { ipcMain } from 'electron' +import { networkInterfaces } from 'os' +import QRCode from 'qrcode' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' + +export type NetworkInterface = { + name: string + address: string +} + +// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't +// connectable from a mobile device. We enumerate all non-internal IPv4 +// addresses so the user can choose which one to advertise in the QR code +// (e.g. LAN vs Tailscale). +function getNetworkInterfaces(): NetworkInterface[] { + const result: NetworkInterface[] = [] + const interfaces = networkInterfaces() + for (const [name, addrs] of Object.entries(interfaces)) { + if (!addrs) { + continue + } + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + result.push({ name, address: addr.address }) + } + } + } + return result +} + +function getLanAddress(): string | null { + const ifaces = getNetworkInterfaces() + return ifaces.length > 0 ? ifaces[0]!.address : null +} + +// Why: the mobile IPC handlers provide the renderer with QR code pairing data, +// device management, and WebSocket readiness status. They depend on the +// OrcaRuntimeRpcServer because it owns the device registry and TLS state. + +export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { + ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({ + interfaces: getNetworkInterfaces() + })) + + ipcMain.handle( + 'mobile:getPairingQR', + async (_event, args?: { address?: string; rotate?: boolean }) => { + const rawEndpoint = rpcServer.getWebSocketEndpoint() + const registry = rpcServer.getDeviceRegistry() + if (!rawEndpoint || !registry) { + return { available: false as const } + } + + // Why: allow the caller to specify which network interface address to + // embed in the QR code. This supports overlay networks (Tailscale, + // ZeroTier) where the default LAN IP isn't reachable from the phone. + const ip = args?.address ?? getLanAddress() + if (!ip) { + return { available: false as const } + } + const endpoint = rawEndpoint.replace('0.0.0.0', ip) + + // Why: coalesce repeated QR regenerations onto a single never-scanned + // pending token so the copy-button flow doesn't accumulate orphaned + // device credentials forever. The token graduates to a real entry when + // a phone actually connects (lastSeenAt > 0). When the caller passes + // `rotate: true` (explicit "Regenerate" intent because the prior token + // may have been exposed), we discard any pending token and mint a fresh + // one so the new QR carries a different credential. + const name = `Mobile ${new Date().toLocaleDateString()}` + const device = args?.rotate + ? registry.rotatePendingDevice(name) + : registry.getOrCreatePendingDevice(name) + + const publicKeyB64 = rpcServer.getE2EEPublicKey() + if (!publicKeyB64) { + return { available: false as const } + } + + const url = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: device.token, + publicKeyB64 + }) + + const qrDataUrl = await QRCode.toDataURL(url, { + errorCorrectionLevel: 'M', + margin: 2, + width: 256 + }) + + return { + available: true as const, + qrDataUrl, + pairingUrl: url, + endpoint, + deviceId: device.deviceId + } + } + ) + + ipcMain.handle('mobile:listDevices', () => { + const registry = rpcServer.getDeviceRegistry() + if (!registry) { + return { devices: [] } + } + // Why: devices with lastSeenAt === 0 were created during QR generation + // but never actually scanned/connected. Showing them as "paired" is + // misleading, so we filter them out. + return { + devices: registry + .listDevices() + .filter((d) => d.lastSeenAt > 0) + .map((d) => ({ + deviceId: d.deviceId, + name: d.name, + pairedAt: d.pairedAt, + lastSeenAt: d.lastSeenAt + })) + } + }) + + ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => { + const registry = rpcServer.getDeviceRegistry() + if (!registry) { + return { revoked: false } + } + return { revoked: registry.removeDevice(args.deviceId) } + }) + + ipcMain.handle('mobile:isWebSocketReady', () => { + return { + ready: rpcServer.getWebSocketEndpoint() !== null, + endpoint: rpcServer.getWebSocketEndpoint() + } + }) +} diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index fbe6c508348..2d1e6c39188 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -1,5 +1,8 @@ /* eslint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' const { removeHandlerMock, @@ -62,9 +65,12 @@ import { } from './notifications' describe('registerNotificationHandlers', () => { + let tempDir: string + beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-03-28T16:00:00Z')) + tempDir = mkdtempSync(join(tmpdir(), 'orca-notification-test-')) removeHandlerMock.mockReset() handleMock.mockReset() notificationCtorMock.mockClear() @@ -77,6 +83,10 @@ describe('registerNotificationHandlers', () => { getAllWindowsMock.mockReturnValue([]) }) + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + function getDispatchHandler(): (event: unknown, args: unknown) => unknown { const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:dispatch') if (!call) { @@ -85,6 +95,24 @@ describe('registerNotificationHandlers', () => { return call[1] as (event: unknown, args: unknown) => unknown } + function getLoadSoundHandler(): (event: unknown) => Promise { + const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:loadSound') + if (!call) { + throw new Error('notifications:loadSound handler not registered') + } + return call[1] as (event: unknown) => Promise + } + + function getResolveSoundPathHandler(): (event: unknown) => unknown { + const call = handleMock.mock.calls.find( + (c: unknown[]) => c[0] === 'notifications:resolveSoundPath' + ) + if (!call) { + throw new Error('notifications:resolveSoundPath handler not registered') + } + return call[1] as (event: unknown) => unknown + } + it('registers the IPC handler', () => { registerNotificationHandlers({ getSettings: () => ({ @@ -171,6 +199,28 @@ describe('registerNotificationHandlers', () => { expect(notificationShowMock).toHaveBeenCalledTimes(1) }) + it('silences the native notification when a custom sound is configured', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true, + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + } + }) + } as never) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(notificationCtorMock).toHaveBeenCalledWith({ + title: 'Orca notifications are on', + body: 'This is a test notification from Orca.', + silent: true + }) + }) + it('returns source-disabled when the specific source toggle is off', () => { registerNotificationHandlers({ getSettings: () => ({ @@ -242,6 +292,87 @@ describe('registerNotificationHandlers', () => { }) expect(notificationShowMock).toHaveBeenCalledTimes(1) }) + + it('loads allowed custom sound files for preload playback', async () => { + const soundPath = join(tempDir, 'sound.ogg') + writeFileSync(soundPath, Buffer.from([1, 2, 3])) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getLoadSoundHandler() + await expect(handler({})).resolves.toMatchObject({ + ok: true, + data: new Uint8Array([1, 2, 3]), + mimeType: 'audio/ogg' + }) + }) + + it('rejects unsupported custom sound file types', async () => { + const soundPath = join(tempDir, 'sound.txt') + writeFileSync(soundPath, 'not audio') + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getLoadSoundHandler() + await expect(handler({})).resolves.toEqual({ + ok: false, + reason: 'unsupported-type' + }) + }) + + it('resolves the sound path without reading the file', () => { + const soundPath = join(tempDir, 'sound.ogg') + writeFileSync(soundPath, Buffer.from([1, 2, 3])) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: soundPath + } + }) + } as never) + + const handler = getResolveSoundPathHandler() + expect(handler({})).toEqual({ ok: true, path: soundPath }) + }) + + it('rejects unsupported types from resolveSoundPath without touching the disk', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: '/some/where/sound.txt' + } + }) + } as never) + + const handler = getResolveSoundPathHandler() + expect(handler({})).toEqual({ ok: false, reason: 'unsupported-type' }) + }) }) describe('triggerStartupNotificationRegistration', () => { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index eb69159bba8..e67b3fa08c1 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,8 +1,25 @@ import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron' +import { readFile, stat } from 'node:fs/promises' +import { extname, isAbsolute, normalize } from 'node:path' import type { Store } from '../persistence' -import type { NotificationDispatchRequest, NotificationDispatchResult } from '../../shared/types' +import type { + NotificationDispatchRequest, + NotificationDispatchResult, + NotificationPermissionStatusResult, + NotificationSoundDataResult +} from '../../shared/types' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' const NOTIFICATION_COOLDOWN_MS = 5000 +const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024 +const NOTIFICATION_SOUND_MIME_BY_EXTENSION: ReadonlyMap = new Map([ + ['.ogg', 'audio/ogg'], + ['.mp3', 'audio/mpeg'], + ['.wav', 'audio/wav'], + ['.m4a', 'audio/mp4'], + ['.aac', 'audio/aac'], + ['.flac', 'audio/flac'] +]) // Why: Electron Notification objects are normal JS objects — if the only // reference is a local variable inside the ipcMain handler, the GC can @@ -11,10 +28,12 @@ const NOTIFICATION_COOLDOWN_MS = 5000 // strong reference until the notification is clicked or closed. const activeNotifications = new Set() -export function registerNotificationHandlers(store: Store): void { +export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { const recentNotifications = new Map() ipcMain.removeHandler('notifications:openSystemSettings') + ipcMain.removeHandler('notifications:getPermissionStatus') + ipcMain.removeHandler('notifications:requestPermission') ipcMain.handle('notifications:openSystemSettings', (): void => { if (process.platform === 'darwin') { // Deep-link into the macOS Notifications settings pane. @@ -24,10 +43,44 @@ export function registerNotificationHandlers(store: Store): void { } }) + // Why: Electron's main-process `Notification` class exposes no synchronous + // way to read macOS auth status — the renderer-side `Notification.permission` + // does not exist here. We expose what we can reliably observe: whether the + // platform supports notifications and whether we've already kicked off the + // first-permission prompt. A 'denied' OS result is invisible to us; the + // dispatch path simply won't deliver in that case, which the user can + // diagnose via the System Settings deep-link. + const getPermissionStatus = (): NotificationPermissionStatusResult => ({ + supported: Notification.isSupported(), + platform: process.platform, + requested: store.getUI().notificationPermissionRequested === true + }) + + ipcMain.handle('notifications:getPermissionStatus', getPermissionStatus) + ipcMain.handle('notifications:requestPermission', (): NotificationPermissionStatusResult => { + triggerStartupNotificationRegistration(store) + return getPermissionStatus() + }) + ipcMain.removeHandler('notifications:dispatch') ipcMain.handle( 'notifications:dispatch', (_event, args: NotificationDispatchRequest): NotificationDispatchResult => { + // Why: mobile push is independent of desktop notification guards. + // The user's phone should receive the notification even when the desktop + // window is focused (suppressWhenFocused), Electron notifications aren't + // supported, or the desktop is in cooldown. The mobile client decides + // independently whether to show based on its own app state. + if (runtime) { + const opts = buildNotificationOptions(args) + runtime.dispatchMobileNotification({ + source: args.source, + title: opts.title, + body: opts.body, + worktreeId: args.worktreeId + }) + } + if (!Notification.isSupported()) { return { delivered: false, reason: 'not-supported' } } @@ -74,7 +127,11 @@ export function registerNotificationHandlers(store: Store): void { } } - const notification = new Notification(buildNotificationOptions(args)) + const notificationOptions = buildNotificationOptions(args) + if (settings.customSoundPath) { + notificationOptions.silent = true + } + const notification = new Notification(notificationOptions) // Why: prevent GC from collecting the notification (and its click // handler) while it's still visible in macOS Notification Center. @@ -119,9 +176,68 @@ export function registerNotificationHandlers(store: Store): void { } notification.show() + return { delivered: true } } ) + + // Why: the preload caches the decoded blob keyed by path. Returning just + // the validated path lets it skip the 10MB IPC round-trip on every dispatch + // when the user's selection hasn't changed — terminal-bell bursts can fire + // many notifications in seconds. + ipcMain.removeHandler('notifications:resolveSoundPath') + ipcMain.handle( + 'notifications:resolveSoundPath', + (): + | { ok: true; path: string } + | { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } => { + const pathValue = store.getSettings().notifications.customSoundPath + if (!pathValue) { + return { ok: false, reason: 'missing-path' } + } + const normalizedPath = normalize(pathValue) + if (!isAbsolute(normalizedPath)) { + return { ok: false, reason: 'invalid-path' } + } + if (!NOTIFICATION_SOUND_MIME_BY_EXTENSION.has(extname(normalizedPath).toLowerCase())) { + return { ok: false, reason: 'unsupported-type' } + } + return { ok: true, path: normalizedPath } + } + ) + + ipcMain.removeHandler('notifications:loadSound') + ipcMain.handle('notifications:loadSound', async (): Promise => { + const pathValue = store.getSettings().notifications.customSoundPath + if (!pathValue) { + return { ok: false, reason: 'missing-path' } + } + + const normalizedPath = normalize(pathValue) + if (!isAbsolute(normalizedPath)) { + return { ok: false, reason: 'invalid-path' } + } + + const mimeType = NOTIFICATION_SOUND_MIME_BY_EXTENSION.get(extname(normalizedPath).toLowerCase()) + if (!mimeType) { + return { ok: false, reason: 'unsupported-type' } + } + + try { + const fileStat = await stat(normalizedPath) + if (!fileStat.isFile()) { + return { ok: false, reason: 'invalid-path' } + } + if (fileStat.size > MAX_NOTIFICATION_SOUND_BYTES) { + return { ok: false, reason: 'too-large' } + } + + const data = await readFile(normalizedPath) + return { ok: true, data: new Uint8Array(data), mimeType, path: normalizedPath } + } catch { + return { ok: false, reason: 'read-failed' } + } + }) } /** diff --git a/src/main/ipc/onboarding.ts b/src/main/ipc/onboarding.ts new file mode 100644 index 00000000000..c5449d6a80b --- /dev/null +++ b/src/main/ipc/onboarding.ts @@ -0,0 +1,16 @@ +import { ipcMain } from 'electron' +import { sanitizeOnboardingUpdate, type Store } from '../persistence' +import type { OnboardingState } from '../../shared/types' + +export function registerOnboardingHandlers(store: Store): void { + ipcMain.removeHandler('onboarding:get') + ipcMain.removeHandler('onboarding:update') + + ipcMain.handle('onboarding:get', (): OnboardingState => store.getOnboarding()) + // Why: never trust renderer input — a compromised/buggy caller could send + // unknown keys or wrong-typed values that would poison persisted state. + // Run every update through the shared whitelist sanitizer. + ipcMain.handle('onboarding:update', (_event, updates: unknown): OnboardingState => { + return store.updateOnboarding(sanitizeOnboardingUpdate(updates)) + }) +} diff --git a/src/main/ipc/pet-bundle.test.ts b/src/main/ipc/pet-bundle.test.ts new file mode 100644 index 00000000000..f8d4f880f4b --- /dev/null +++ b/src/main/ipc/pet-bundle.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { + CODEX_PET_ANIMATIONS, + CODEX_PET_FRAME, + CODEX_PET_SPRITESHEET_PATH, + applyCodexPetDefaults, + readWebpDimensionsFromBuffer +} from './pet-bundle' + +function u32(value: number): Buffer { + const buffer = Buffer.alloc(4) + buffer.writeUInt32LE(value, 0) + return buffer +} + +function u24(value: number): Buffer { + return Buffer.from([value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff]) +} + +function webpVp8x(width: number, height: number): Buffer { + const payload = Buffer.concat([Buffer.from([0, 0, 0, 0]), u24(width - 1), u24(height - 1)]) + return Buffer.concat([ + Buffer.from('RIFF'), + u32(4 + 8 + payload.byteLength), + Buffer.from('WEBP'), + Buffer.from('VP8X'), + u32(payload.byteLength), + payload + ]) +} + +describe('applyCodexPetDefaults', () => { + it('fills Codex pet manifests that omit Orca sprite metadata', () => { + const manifest = applyCodexPetDefaults({ id: 'apupepe', displayName: 'Pepe' }) + + expect(manifest.spritesheetPath).toBe(CODEX_PET_SPRITESHEET_PATH) + expect(manifest.frame).toEqual(CODEX_PET_FRAME) + expect(manifest.defaultAnimation).toBe('idle') + expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS) + expect(Object.keys(manifest.animations ?? [])).toEqual([ + 'idle', + 'running-right', + 'running-left', + 'waving', + 'jumping', + 'failed', + 'waiting', + 'running', + 'review' + ]) + }) + + it('fills Codex pet manifests that declare only spritesheetPath', () => { + const manifest = applyCodexPetDefaults({ + id: 'itachi', + displayName: 'Itachi', + spritesheetPath: 'spritesheet.webp' + }) + + expect(manifest.spritesheetPath).toBe(CODEX_PET_SPRITESHEET_PATH) + expect(manifest.frame).toEqual(CODEX_PET_FRAME) + expect(manifest.defaultAnimation).toBe('idle') + expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS) + }) + + it('does not override explicit Orca bundle sprite metadata', () => { + const manifest = applyCodexPetDefaults({ + spritesheetPath: 'custom.png', + frame: { width: 64, height: 64 }, + fps: 12, + defaultAnimation: 'blink', + animations: { blink: { row: 0, frames: 2 } } + }) + + expect(manifest).toEqual({ + spritesheetPath: 'custom.png', + frame: { width: 64, height: 64 }, + fps: 12, + defaultAnimation: 'blink', + animations: { blink: { row: 0, frames: 2 } } + }) + }) + + it('defaults only spritesheetPath when explicit sprite metadata is present', () => { + const manifest = applyCodexPetDefaults({ + frame: { width: 64, height: 64 }, + animations: { blink: { row: 0, frames: 2 } } + }) + + expect(manifest.spritesheetPath).toBe(CODEX_PET_SPRITESHEET_PATH) + expect(manifest.frame).toEqual({ width: 64, height: 64 }) + expect(manifest.animations).toEqual({ blink: { row: 0, frames: 2 } }) + expect(manifest.defaultAnimation).toBeUndefined() + }) +}) + +describe('readWebpDimensionsFromBuffer', () => { + it('reads VP8X WebP canvas dimensions without decoding pixels', () => { + expect(readWebpDimensionsFromBuffer(webpVp8x(1536, 1872))).toEqual({ + width: 1536, + height: 1872 + }) + }) + + it('returns null for non-WebP data', () => { + expect(readWebpDimensionsFromBuffer(Buffer.from('not an image'))).toBeNull() + }) +}) diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts new file mode 100644 index 00000000000..1d231ebaa21 --- /dev/null +++ b/src/main/ipc/pet-bundle.ts @@ -0,0 +1,127 @@ +import type { SpriteAnimation } from '../../shared/types' + +export type PetManifestLike = { + id?: string + displayName?: string + description?: string + spritesheetPath?: string + frame?: { + width: number + height: number + } + fps?: number + defaultAnimation?: string + animations?: Record +} + +export type ResolvedPetManifest = T & + PetManifestLike & { + spritesheetPath: string + } + +export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp' +export const CODEX_PET_FRAME = { width: 192, height: 208 } as const +export const CODEX_PET_DEFAULT_ANIMATION = 'idle' +export const CODEX_PET_DEFAULT_FPS = 8 + +export const CODEX_PET_ANIMATIONS: Record = { + idle: { row: 0, frames: 6 }, + 'running-right': { row: 1, frames: 8 }, + 'running-left': { row: 2, frames: 8 }, + waving: { row: 3, frames: 4 }, + jumping: { row: 4, frames: 5 }, + failed: { row: 5, frames: 8 }, + waiting: { row: 6, frames: 6 }, + running: { row: 7, frames: 6 }, + review: { row: 8, frames: 6 } +} + +function isCodexPetSpritePath(spritesheetPath: string | undefined): boolean { + return spritesheetPath === undefined || /(^|[/\\])spritesheet\.webp$/i.test(spritesheetPath) +} + +export function applyCodexPetDefaults( + manifest: T +): ResolvedPetManifest { + const shouldApplyCodexLayout = + isCodexPetSpritePath(manifest.spritesheetPath) && + manifest.frame === undefined && + manifest.animations === undefined + + if (!shouldApplyCodexLayout) { + return { + ...manifest, + spritesheetPath: manifest.spritesheetPath ?? CODEX_PET_SPRITESHEET_PATH + } as ResolvedPetManifest + } + + return { + ...manifest, + spritesheetPath: manifest.spritesheetPath ?? CODEX_PET_SPRITESHEET_PATH, + frame: manifest.frame ?? CODEX_PET_FRAME, + fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS, + defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION, + animations: manifest.animations ?? CODEX_PET_ANIMATIONS + } +} + +function readUInt24LE(buffer: Buffer, offset: number): number { + return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) +} + +export function readWebpDimensionsFromBuffer( + buffer: Buffer +): { width: number; height: number } | null { + if ( + buffer.byteLength < 20 || + buffer.toString('ascii', 0, 4) !== 'RIFF' || + buffer.toString('ascii', 8, 12) !== 'WEBP' + ) { + return null + } + + let offset = 12 + while (offset + 8 <= buffer.byteLength) { + const chunkType = buffer.toString('ascii', offset, offset + 4) + const chunkSize = buffer.readUInt32LE(offset + 4) + const dataOffset = offset + 8 + const dataEnd = dataOffset + chunkSize + if (dataEnd > buffer.byteLength) { + return null + } + + if (chunkType === 'VP8X' && chunkSize >= 10) { + return { + width: readUInt24LE(buffer, dataOffset + 4) + 1, + height: readUInt24LE(buffer, dataOffset + 7) + 1 + } + } + + if (chunkType === 'VP8L' && chunkSize >= 5 && buffer[dataOffset] === 0x2f) { + const b0 = buffer[dataOffset + 1] + const b1 = buffer[dataOffset + 2] + const b2 = buffer[dataOffset + 3] + const b3 = buffer[dataOffset + 4] + return { + width: 1 + (((b1 & 0x3f) << 8) | b0), + height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)) + } + } + + if ( + chunkType === 'VP8 ' && + chunkSize >= 10 && + buffer[dataOffset + 3] === 0x9d && + buffer[dataOffset + 4] === 0x01 && + buffer[dataOffset + 5] === 0x2a + ) { + const width = buffer.readUInt16LE(dataOffset + 6) & 0x3fff + const height = buffer.readUInt16LE(dataOffset + 8) & 0x3fff + return width > 0 && height > 0 ? { width, height } : null + } + + offset = dataEnd + (chunkSize % 2) + } + + return null +} diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts new file mode 100644 index 00000000000..de3d11b59e3 --- /dev/null +++ b/src/main/ipc/pet.ts @@ -0,0 +1,520 @@ +/* eslint-disable max-lines */ +import { app, BrowserWindow, dialog, ipcMain, nativeImage } from 'electron' +import { copyFile, mkdir, open, readFile, rename, rm, stat, lstat } from 'node:fs/promises' +import { constants as fsConstants, createWriteStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' +import { randomUUID } from 'node:crypto' +import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path' +import { z } from 'zod' +import type { CustomPet } from '../../shared/types' +import { + applyCodexPetDefaults, + readWebpDimensionsFromBuffer, + type PetManifestLike, + type ResolvedPetManifest +} from './pet-bundle' + +// Why: image-only pet uploads. Static + animated variants render natively +// via , so no 3D engine is needed. Main owns the accepted-format table as +// the single source of truth for what the renderer will try to display. +const IMAGE_FORMATS: Record = { + '.png': 'image/png', + '.apng': 'image/apng', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml' +} + +function classifyFile(src: string): { mimeType: string; ext: string } | null { + const ext = extname(src).toLowerCase() + const mime = IMAGE_FORMATS[ext] + if (!mime) { + return null + } + return { mimeType: mime, ext } +} + +// Why: keep using the legacy sidekicks folder so existing user-uploaded pets +// keep rendering after the product rename. The renderer only knows CustomPet.id; +// main resolves it to an absolute path inside this folder. +function getPetsDir(): string { + return join(app.getPath('userData'), 'sidekicks', 'custom') +} + +const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL. +const MAX_MANIFEST_BYTES = 64 * 1024 // pet.json is tiny by spec; cap to defend against a malicious bundle stuffing megabytes into the manifest. + +function isSafeId(id: string): boolean { + // UUIDs only; blocks path traversal and unexpected characters. Storage ids + // are always generated in main (never sourced from the bundle's manifest.id), + // so this regex is the canonical gate for any fs path that includes the id. + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) +} + +function resolvePetFile(id: string, fileName: string, kind: 'image' | 'bundle'): string | null { + if (!isSafeId(id)) { + return null + } + const safeName = basename(fileName) + const root = normalize(getPetsDir()) + let filePath: string + if (kind === 'bundle') { + // Bundle layout: custom//. fileName is just the spritesheet + // basename (e.g. "spritesheet.webp"); pet.json is read by main only and + // never served to the renderer. + filePath = normalize(join(root, id, safeName)) + const bundleDir = normalize(join(root, id)) + sep + if (!filePath.startsWith(bundleDir)) { + return null + } + return filePath + } + // Legacy image layout: custom/.. Filename must start with the id + // so the prefix check catches any edge case that slipped the regex. + if (!safeName.startsWith(`${id}.`)) { + return null + } + filePath = normalize(join(root, safeName)) + if (!filePath.startsWith(root + sep)) { + return null + } + return filePath +} + +const PetManifestSchema = z + .object({ + id: z.string().min(1).max(128).optional(), + displayName: z.string().min(1).max(120).optional(), + description: z.string().max(2000).optional(), + spritesheetPath: z + .string() + .min(1) + .max(255) + // Why: belt-and-suspenders against malicious manifests — downstream + // resolve+prefix check still runs as defense in depth. + .refine( + (p) => !p.includes('\0') && !p.startsWith('/') && !p.startsWith('\\') && !p.includes('..'), + 'invalid spritesheetPath' + ) + .optional(), + frame: z + .object({ + width: z.number().int().positive().max(1024), + height: z.number().int().positive().max(1024) + }) + .optional(), + fps: z.number().positive().max(60).optional(), + defaultAnimation: z.string().min(1).max(64).optional(), + animations: z + .record( + z.string().min(1).max(64), + z.object({ + row: z.number().int().min(0).max(256), + frames: z.number().int().positive().max(512) + }) + ) + .optional() + }) + // Why: tolerate unknown top-level fields. Pet-bundle generators may emit + // descriptive metadata (e.g. "kind": "person") we don't consume yet, and + // strict mode would reject those imports with a confusing "unrecognized + // key" error instead of just ignoring the extras. + .loose() + +type PetManifest = z.infer & PetManifestLike + +// Why: renderer-supplied IPC inputs are untrusted — validate shape before any +// path resolution. resolvePetFile still gates the actual filesystem path. +const PetFileRequestSchema = z.object({ + id: z.string(), + fileName: z.string(), + kind: z.enum(['image', 'bundle']).optional() +}) + +async function readSheetDimensions( + buffer: Buffer +): Promise<{ width: number; height: number } | null> { + // Why: Electron's nativeImage can fail to decode some valid WebP variants + // even though Chromium can render them. Sprite sheets only need the canvas + // size, so read WebP dimensions from the container header before falling + // back to native decoding. + const webpDims = readWebpDimensionsFromBuffer(buffer) + if (webpDims) { + return webpDims + } + + // Why: nativeImage decodes PNG/JPEG/GIF/WebP/BMP. SVG isn't supported here + // (vector → no integer pixel grid), so pet bundles must use a raster sheet. + const image = nativeImage.createFromBuffer(buffer) + if (image.isEmpty()) { + return null + } + const size = image.getSize() + if (size.width <= 0 || size.height <= 0) { + return null + } + return { width: size.width, height: size.height } +} + +// Why: TOCTOU defense — between the `isSymlink` check and `copyFile`, a local +// attacker with write access to the bundle dir could swap the file with a +// symlink (copyFile follows symlinks). Open with O_NOFOLLOW so the open fails +// outright if the path is a symlink at the moment of open, then stream from +// the fd. On platforms without O_NOFOLLOW (Windows), the constant is undefined +// and we fall back to copyFile — symlinks aren't a meaningful threat there. +async function copyFileNoFollow(src: string, dest: string): Promise { + const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0 + if (noFollow === 0) { + await copyFile(src, dest) + return + } + const fh = await open(src, fsConstants.O_RDONLY | noFollow) + try { + await pipeline(fh.createReadStream({ autoClose: false }), createWriteStream(dest)) + } finally { + await fh.close() + } +} + +async function isSymlink(path: string): Promise { + try { + const s = await lstat(path) + return s.isSymbolicLink() + } catch { + return false + } +} + +export function registerPetHandlers(): void { + ipcMain.handle('pet:import', async (event): Promise => { + const senderWindow = + BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() + const options: Electron.OpenDialogOptions = { + title: 'Pick pet', + properties: ['openFile'], + // Why: single filter and no `apng` extension. macOS file dialogs map + // filter extensions to UTIs; `apng` has no registered UTI, so including + // it can drop sibling extensions (notably `webp`) from the allowed set. + // APNG files carry the `.png` extension and are detected from magic + // bytes by the browser. + filters: [ + { + name: 'Pet image', + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] + } + ] + } + const result = senderWindow + ? await dialog.showOpenDialog(senderWindow, options) + : await dialog.showOpenDialog(options) + if (result.canceled || result.filePaths.length === 0) { + return null + } + const src = result.filePaths[0] + const classified = classifyFile(src) + if (!classified) { + throw new Error('Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG.') + } + let srcStat: Awaited> + try { + srcStat = await stat(src) + } catch { + throw new Error('Could not read the selected file.') + } + if (!srcStat.isFile()) { + throw new Error('Selected path is not a file') + } + if (srcStat.size > MAX_BYTES) { + throw new Error( + `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_BYTES / (1024 * 1024)} MB.` + ) + } + + const dir = getPetsDir() + await mkdir(dir, { recursive: true }) + const id = randomUUID() + // Why: preserve original extension in the on-disk name so pet:read can + // rebuild the right Blob MIME via resolvePetFile without a separate + // lookup. The extension is only ever written by main (never the renderer). + const fileName = `${id}${classified.ext}` + const dest = join(dir, fileName) + try { + await copyFile(src, dest) + } catch { + await rm(dest, { force: true }).catch(() => {}) + throw new Error('Could not save the pet.') + } + + const rawLabel = basename(src, extname(src)).trim() + const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Custom pet' + return { + id, + label, + fileName, + mimeType: classified.mimeType, + kind: 'image' + } + }) + + ipcMain.handle('pet:importPetBundle', async (event): Promise => { + const senderWindow = + BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() + // Why: the bundle is a folder. macOS users may also pick `pet.json` itself + // when Finder is set to show package contents — the post-pick logic walks + // up to the parent directory in that case. + const options: Electron.OpenDialogOptions = { + title: 'Pick a .codex-pet bundle', + properties: ['openFile', 'openDirectory', 'treatPackageAsDirectory'] + } + const result = senderWindow + ? await dialog.showOpenDialog(senderWindow, options) + : await dialog.showOpenDialog(options) + if (result.canceled || result.filePaths.length === 0) { + return null + } + const picked = result.filePaths[0] + let bundleDir: string + try { + const pickedStat = await stat(picked) + bundleDir = pickedStat.isDirectory() ? picked : dirname(picked) + } catch { + throw new Error('Could not read the selected path.') + } + + const manifestPath = join(bundleDir, 'pet.json') + let manifestStat: Awaited> + try { + manifestStat = await stat(manifestPath) + } catch { + throw new Error('Bundle is missing pet.json.') + } + if (!manifestStat.isFile() || manifestStat.size > MAX_MANIFEST_BYTES) { + throw new Error('pet.json is invalid.') + } + if (await isSymlink(manifestPath)) { + throw new Error('pet.json must not be a symlink.') + } + + let manifest: ResolvedPetManifest + try { + const raw = await readFile(manifestPath, 'utf8') + // Why: defend against TOCTOU between stat and read — the file could have + // grown after the stat check. + if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) { + throw new Error('pet.json exceeded the manifest size limit.') + } + manifest = applyCodexPetDefaults(PetManifestSchema.parse(JSON.parse(raw))) + } catch (error) { + throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`) + } + + // Why: spritesheetPath is bundle-relative. Codex pet.json files omit this + // path and use a fixed `spritesheet.webp`; applyCodexPetDefaults fills + // that shape before path validation. Reject absolute paths and any resolved + // path that escapes the bundle directory. Also reject symlinks so a + // malicious bundle can't reach outside via a sibling link. + if (isAbsolute(manifest.spritesheetPath)) { + throw new Error('spritesheetPath must be relative to the bundle.') + } + const sheetSrc = resolve(bundleDir, manifest.spritesheetPath) + const bundleResolved = resolve(bundleDir) + if (sheetSrc === bundleResolved) { + throw new Error('spritesheetPath must point to a file, not the bundle root.') + } + const bundleRoot = bundleResolved + sep + // Why: NTFS/macOS HFS+ default volumes are case-insensitive — a path like + // `BUNDLE\sheet.png` is still inside `bundle\`. Compare lowercased on + // Windows so the prefix check isn't bypassed by case differences. + const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s + if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) { + throw new Error('spritesheetPath escapes the bundle.') + } + if (await isSymlink(sheetSrc)) { + throw new Error('spritesheet must not be a symlink.') + } + const sheetClass = classifyFile(sheetSrc) + if (!sheetClass || sheetClass.ext === '.svg') { + // SVG can't be used as a sprite sheet (no pixel grid). + throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.') + } + let sheetStat: Awaited> + try { + sheetStat = await stat(sheetSrc) + } catch { + throw new Error('Spritesheet file not found.') + } + if (!sheetStat.isFile()) { + throw new Error('Spritesheet path is not a file.') + } + if (sheetStat.size > MAX_BYTES) { + throw new Error( + `Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).` + ) + } + + let sprite: NonNullable | undefined + if (manifest.frame) { + // Why: only decode the sheet when we need to validate frame layout. + // nativeImage may fail on some WebP variants in headless contexts, and + // bundles without `frame` render as a static image where dimensions + // don't matter. + const sheetBuf = await readFile(sheetSrc) + // Why: defend against TOCTOU — file may have grown between stat and read. + if (sheetBuf.byteLength > MAX_BYTES) { + throw new Error('Spritesheet exceeded the size limit.') + } + const dims = await readSheetDimensions(sheetBuf) + if (!dims) { + throw new Error('Could not decode the spritesheet image.') + } + const { width: fw, height: fh } = manifest.frame + if (dims.width % fw !== 0 || dims.height % fh !== 0) { + throw new Error( + `Spritesheet ${dims.width}×${dims.height} is not a clean multiple of frame ${fw}×${fh}.` + ) + } + const columns = dims.width / fw + const rows = dims.height / fh + if (manifest.animations) { + for (const [name, anim] of Object.entries(manifest.animations)) { + if (anim.row >= rows) { + throw new Error(`Animation "${name}" references row ${anim.row} but sheet has ${rows}.`) + } + if (anim.frames > columns) { + throw new Error( + `Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.` + ) + } + } + if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) { + throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`) + } + } + sprite = { + frameWidth: fw, + frameHeight: fh, + columns, + rows, + sheetWidth: dims.width, + sheetHeight: dims.height, + fps: manifest.fps ?? 8, + defaultAnimation: manifest.defaultAnimation, + animations: manifest.animations + } + } + + // Why: storage id is always a fresh UUID — the manifest's `id` is purely + // a display hint. This guards against (a) collisions with bundled + // pet ids, (b) failing isSafeId, and (c) re-import clobbering an + // earlier copy of the same bundle. + const id = randomUUID() + const root = getPetsDir() + await mkdir(root, { recursive: true }) + const destDir = join(root, id) + const sheetExt = sheetClass.ext + const sheetFileName = `spritesheet${sheetExt}` + // Why: stage the bundle into a sibling .tmp directory and atomically rename + // into place so destDir only appears once both files are written. Avoids + // half-imported bundles if a copy fails midway. + const tmpDir = `${destDir}.tmp` + try { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + await mkdir(tmpDir, { recursive: true }) + await copyFileNoFollow(sheetSrc, join(tmpDir, sheetFileName)) + await copyFileNoFollow(manifestPath, join(tmpDir, 'pet.json')) + await rename(tmpDir, destDir) + } catch { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + throw new Error('Could not save the pet bundle.') + } + + const rawLabel = (manifest.displayName ?? manifest.id ?? basename(bundleDir)).trim() + const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Pet bundle' + return { + id, + label, + fileName: sheetFileName, + mimeType: sheetClass.mimeType, + kind: 'bundle', + sprite, + // Why: carries manifest.fps for detected-frame bundles where `sprite` + // is undefined — renderer falls back to this when sprite is absent. + ...(manifest.fps !== undefined ? { spriteFps: manifest.fps } : {}) + } + }) + + ipcMain.handle( + 'pet:read', + async ( + _event, + id: string, + fileName: string, + kind?: 'image' | 'bundle' + ): Promise => { + // Why: validate IPC inputs before any path logic — renderer is not + // trusted to send strings of the right shape. + let parsed: z.infer + try { + parsed = PetFileRequestSchema.parse({ id, fileName, kind }) + } catch { + throw new Error('Invalid pet:read arguments') + } + // Why: missing kind defaults to 'image' for backwards compatibility with + // pre-bundle persisted state. + const filePath = resolvePetFile(parsed.id, parsed.fileName, parsed.kind ?? 'image') + if (!filePath) { + return null + } + try { + const buf = await readFile(filePath) + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + } catch (error) { + console.warn('[pet-overlay] pet:read failed', error) + return null + } + } + ) + + ipcMain.handle( + 'pet:delete', + async (_event, id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => { + // Why: validate IPC inputs before any path logic. + let parsed: z.infer + try { + parsed = PetFileRequestSchema.parse({ id, fileName, kind }) + } catch { + throw new Error('Invalid pet:delete arguments') + } + if (!isSafeId(parsed.id)) { + return + } + if ((parsed.kind ?? 'image') === 'bundle') { + // Why: bundle imports own a whole directory. isSafeId already gates id; + // we still build the path under the pets root and verify the + // prefix before recursive removal as defense in depth. + const root = normalize(getPetsDir()) + const target = normalize(join(root, parsed.id)) + if (!target.startsWith(root + sep)) { + return + } + try { + await rm(target, { recursive: true, force: true }) + } catch (error) { + console.warn('[pet-overlay] pet:delete (bundle) failed', error) + } + return + } + const filePath = resolvePetFile(parsed.id, parsed.fileName, 'image') + if (!filePath) { + return + } + try { + await rm(filePath, { force: true }) + } catch (error) { + console.warn('[pet-overlay] pet:delete failed', error) + } + } + ) +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 3dd925514a6..bc2453aa438 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -198,7 +198,8 @@ describe('preflight', () => { // (3) re-run `which` so newly-installed CLIs appear without a restart. hydrateShellPathMock.mockResolvedValueOnce({ segments: ['/Users/test/.opencode/bin'], - ok: true + ok: true, + failureReason: 'none' }) mergePathSegmentsMock.mockReturnValueOnce(['/Users/test/.opencode/bin']) execFileAsyncMock.mockImplementation(async (command, args) => { @@ -217,18 +218,26 @@ describe('preflight', () => { agents: string[] addedPathSegments: string[] shellHydrationOk: boolean + pathSource: string + pathFailureReason: string } expect(result).toEqual({ agents: ['opencode'], addedPathSegments: ['/Users/test/.opencode/bin'], - shellHydrationOk: true + shellHydrationOk: true, + pathSource: 'shell_hydrate', + pathFailureReason: 'none' }) expect(hydrateShellPathMock).toHaveBeenCalledWith({ force: true }) }) it('still re-detects when the shell spawn fails — relies on the existing PATH', async () => { - hydrateShellPathMock.mockResolvedValueOnce({ segments: [], ok: false }) + hydrateShellPathMock.mockResolvedValueOnce({ + segments: [], + ok: false, + failureReason: 'timeout' + }) execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { throw new Error(`unexpected command ${String(command)}`) @@ -245,13 +254,38 @@ describe('preflight', () => { agents: string[] addedPathSegments: string[] shellHydrationOk: boolean + pathSource: string + pathFailureReason: string } expect(result.shellHydrationOk).toBe(false) expect(result.addedPathSegments).toEqual([]) expect(result.agents).toEqual(['claude']) + // Why: drives the agent_picks `on_path:false` triage in dashboard 1562016. + // Without these fields we cannot distinguish "hydration failed" from + // "user genuinely doesn't have the binary." + expect(result.pathSource).toBe('sync_seed_only') + expect(result.pathFailureReason).toBe('timeout') // Why: when hydration fails, we must not call merge — nothing to merge — // otherwise we'd log a no-op "added 0 segments" event on every refresh. expect(mergePathSegmentsMock).not.toHaveBeenCalled() }) + + it.each(['no_shell', 'spawn_error', 'empty_path'] as const)( + 'classifies pathFailureReason=%s when hydration reports it', + async (failureReason) => { + hydrateShellPathMock.mockResolvedValueOnce({ segments: [], ok: false, failureReason }) + execFileAsyncMock.mockRejectedValue(new Error('not found')) + + registerPreflightHandlers() + + const result = (await handlers['preflight:refreshAgents']()) as { + pathSource: string + pathFailureReason: string + } + + expect(result.pathSource).toBe('sync_seed_only') + expect(result.pathFailureReason).toBe(failureReason) + } + ) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 1fa1dd8c3ce..39b9e3121ca 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -3,6 +3,7 @@ import { execFile } from 'child_process' import { promisify } from 'util' import path from 'path' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getActiveMultiplexer } from './ssh' @@ -69,6 +70,14 @@ export type RefreshAgentsResult = { addedPathSegments: string[] /** True when the shell spawn succeeded. False = relied on existing PATH. */ shellHydrationOk: boolean + /** Whether `detectInstalledAgents` ran against shell-hydrated PATH or only + * the seed list from `patchPackagedProcessPath`. Drives the on_path:false + * triage in tile A on dashboard 1562016. */ + pathSource: PathSource + /** Why hydration failed (or `'none'` on success). Typed against the shared + * alias so the IPC boundary stays in lockstep with the renderer-visible + * enum on `onboardingAgentPickedSchema`. */ + pathFailureReason: ShellHydrationFailureReason } /** @@ -84,7 +93,9 @@ export async function refreshShellPathAndDetectAgents(): Promise { describe('listSessions', () => { it('merges sessions across current + legacy adapters with protocolVersion', async () => { - const current = makeAdapter(4, [makeSession('new-1'), makeSession('new-2')]) + const current = makeAdapter(5, [makeSession('new-1'), makeSession('new-2')]) const legacy = makeAdapter(3, [makeSession('old-1', { protocolVersion: 3 })]) const { registerDaemonManagementHandlers } = await importFresh() getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy])) @@ -132,8 +132,8 @@ describe('pty:management IPC handlers', () => { expect(result.sessions).toHaveLength(3) const byId = new Map(result.sessions.map((s) => [s.sessionId, s])) - expect(byId.get('new-1')?.protocolVersion).toBe(4) - expect(byId.get('new-2')?.protocolVersion).toBe(4) + expect(byId.get('new-1')?.protocolVersion).toBe(5) + expect(byId.get('new-2')?.protocolVersion).toBe(5) expect(byId.get('old-1')?.protocolVersion).toBe(3) }) @@ -152,7 +152,7 @@ describe('pty:management IPC handlers', () => { }) it('tolerates a failing adapter by skipping its sessions', async () => { - const current = makeAdapter(4, [makeSession('new-1')]) + const current = makeAdapter(5, [makeSession('new-1')]) const legacy = makeAdapter(3, []) legacy.listSessions = vi.fn(async () => { throw new Error('legacy socket dead') @@ -205,7 +205,7 @@ describe('pty:management IPC handlers', () => { it('fires one shutdown per initial session and polls until empty', async () => { const currentSessions = [makeSession('new-1'), makeSession('new-2')] const legacySessions = [makeSession('old-1', { protocolVersion: 3 })] - const current = makeAdapter(4, []) + const current = makeAdapter(5, []) const legacy = makeAdapter(3, []) // Why: shutdown removes the session from the adapter's backing list so // the next poll observes the shrinking set — mirrors a daemon that @@ -238,15 +238,15 @@ describe('pty:management IPC handlers', () => { expect(result).toEqual({ killedCount: 3, remainingCount: 0 }) // Each initial session receives exactly one shutdown — no retries. expect(current.shutdown).toHaveBeenCalledTimes(2) - expect(current.shutdown).toHaveBeenCalledWith('new-1', true) - expect(current.shutdown).toHaveBeenCalledWith('new-2', true) + expect(current.shutdown).toHaveBeenCalledWith('new-1', { immediate: true }) + expect(current.shutdown).toHaveBeenCalledWith('new-2', { immediate: true }) expect(legacy.shutdown).toHaveBeenCalledTimes(1) - expect(legacy.shutdown).toHaveBeenCalledWith('old-1', true) + expect(legacy.shutdown).toHaveBeenCalledWith('old-1', { immediate: true }) }) it('reports remainingCount when sessions refuse to die after the poll window', async () => { const sessions = [makeSession('stuck')] - const current = makeAdapter(4, []) + const current = makeAdapter(5, []) current.listSessions = vi.fn(async () => sessions.map(({ protocolVersion: _pv, ...rest }) => rest) ) @@ -275,7 +275,7 @@ describe('pty:management IPC handlers', () => { // "refused to exit" count — the user asked to kill what was alive // when the button was pressed, not to chase new spawns. const liveSessions = [makeSession('a'), makeSession('b')] - const current = makeAdapter(4, []) + const current = makeAdapter(5, []) let pollCalls = 0 current.listSessions = vi.fn(async () => { pollCalls += 1 @@ -301,7 +301,7 @@ describe('pty:management IPC handlers', () => { it('swallows per-session shutdown rejections without stopping the batch', async () => { const sessionsList = [makeSession('a'), makeSession('b')] - const current = makeAdapter(4, []) + const current = makeAdapter(5, []) current.listSessions = vi.fn(async () => sessionsList.map(({ protocolVersion: _pv, ...rest }) => rest) ) @@ -327,8 +327,8 @@ describe('pty:management IPC handlers', () => { const handlers = buildHandlerMap() const result = await runKillAllWithPolls(handlers['pty:management:killAll']) - expect(current.shutdown).toHaveBeenCalledWith('a', true) - expect(current.shutdown).toHaveBeenCalledWith('b', true) + expect(current.shutdown).toHaveBeenCalledWith('a', { immediate: true }) + expect(current.shutdown).toHaveBeenCalledWith('b', { immediate: true }) // 'a' rejected and is still alive → counts as remaining; 'b' reaped. expect(result).toEqual({ killedCount: 1, remainingCount: 1 }) }) @@ -336,7 +336,7 @@ describe('pty:management IPC handlers', () => { describe('killOne', () => { it('routes to the adapter whose protocolVersion owns the session', async () => { - const current = makeAdapter(4, [makeSession('new-1')]) + const current = makeAdapter(5, [makeSession('new-1')]) const legacy = makeAdapter(3, [makeSession('old-1', { protocolVersion: 3 })]) const { registerDaemonManagementHandlers } = await importFresh() getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy])) @@ -348,12 +348,12 @@ describe('pty:management IPC handlers', () => { } expect(result.success).toBe(true) - expect(legacy.shutdown).toHaveBeenCalledWith('old-1', true) + expect(legacy.shutdown).toHaveBeenCalledWith('old-1', { immediate: true }) expect(current.shutdown).not.toHaveBeenCalled() }) it('returns success=false for unknown sessionId', async () => { - const current = makeAdapter(4, [makeSession('new-1')]) + const current = makeAdapter(5, [makeSession('new-1')]) const { registerDaemonManagementHandlers } = await importFresh() getDaemonProviderMock.mockReturnValue(await makeRouter(current)) registerDaemonManagementHandlers() @@ -368,7 +368,7 @@ describe('pty:management IPC handlers', () => { }) it('rejects empty/missing sessionId without hitting the adapter', async () => { - const current = makeAdapter(4, [makeSession('new-1')]) + const current = makeAdapter(5, [makeSession('new-1')]) const { registerDaemonManagementHandlers } = await importFresh() getDaemonProviderMock.mockReturnValue(await makeRouter(current)) registerDaemonManagementHandlers() diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 05d8f4ab109..014aef35d36 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -107,7 +107,7 @@ export function registerDaemonManagementHandlers(): void { // and so a future adapter could honor it. Rejections are swallowed // per-session — remainingCount surfaces truly-stuck sessions in // the toast. - await owner.shutdown(session.sessionId, true).catch(() => {}) + await owner.shutdown(session.sessionId, { immediate: true }).catch(() => {}) }) ) @@ -151,7 +151,7 @@ export function registerDaemonManagementHandlers(): void { return { success: false } } try { - await owner.shutdown(args.sessionId, true) + await owner.shutdown(args.sessionId, { immediate: true }) return { success: true } } catch { return { success: false } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 19e8626ff31..6e7d46ecce1 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -12,6 +12,7 @@ const { statSyncMock, accessSyncMock, mkdirSyncMock, + readFileSyncMock, writeFileSyncMock, chmodSyncMock, getPathMock, @@ -21,7 +22,9 @@ const { buildAgentHookEnvMock, piBuildPtyEnvMock, piClearPtyMock, - isPwshAvailableMock + isPwshAvailableMock, + trackMock, + classifyErrorMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -31,6 +34,7 @@ const { statSyncMock: vi.fn(), accessSyncMock: vi.fn(), mkdirSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), chmodSyncMock: vi.fn(), getPathMock: vi.fn(), @@ -40,7 +44,9 @@ const { openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), - piClearPtyMock: vi.fn() + piClearPtyMock: vi.fn(), + trackMock: vi.fn(), + classifyErrorMock: vi.fn() })) vi.mock('electron', () => ({ @@ -61,6 +67,7 @@ vi.mock('fs', () => ({ statSync: statSyncMock, accessSync: accessSyncMock, mkdirSync: mkdirSyncMock, + readFileSync: readFileSyncMock, writeFileSync: writeFileSyncMock, chmodSync: chmodSyncMock, constants: { @@ -95,6 +102,14 @@ vi.mock('../pi/titlebar-extension-service', () => ({ vi.mock('../pwsh', () => ({ isPwshAvailable: isPwshAvailableMock })) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/classify-error', () => ({ + classifyError: classifyErrorMock +})) import { LocalPtyProvider } from '../providers/local-pty-provider' import { registerPtyHandlers, @@ -103,6 +118,10 @@ import { unregisterSshPtyProvider } from './pty' +const POWERSHELL_PROFILE_COMMAND = expect.stringMatching( + /\. \$PROFILE[\s\S]*ORCA_OPENCODE_CONFIG_DIR[\s\S]*ORCA_PI_CODING_AGENT_DIR[\s\S]*UTF8/ +) + function makeDisposable() { return { dispose: vi.fn() } } @@ -119,10 +138,18 @@ describe('registerPtyHandlers', () => { } const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR + const savedOrcaOpenCodeConfigDir = process.env.ORCA_OPENCODE_CONFIG_DIR + const savedOrcaOpenCodeSourceConfigDir = process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + const savedOrcaPiAgentDir = process.env.ORCA_PI_CODING_AGENT_DIR + const savedOrcaPiSourceAgentDir = process.env.ORCA_PI_SOURCE_AGENT_DIR beforeEach(() => { delete process.env.OPENCODE_CONFIG_DIR + delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + delete process.env.ORCA_OPENCODE_CONFIG_DIR delete process.env.PI_CODING_AGENT_DIR + delete process.env.ORCA_PI_SOURCE_AGENT_DIR + delete process.env.ORCA_PI_CODING_AGENT_DIR handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -132,6 +159,7 @@ describe('registerPtyHandlers', () => { statSyncMock.mockReset() accessSyncMock.mockReset() mkdirSyncMock.mockReset() + readFileSyncMock.mockReset() writeFileSyncMock.mockReset() chmodSyncMock.mockReset() getPathMock.mockReset() @@ -142,6 +170,8 @@ describe('registerPtyHandlers', () => { piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() + trackMock.mockReset() + classifyErrorMock.mockReset() mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() @@ -151,12 +181,15 @@ describe('registerPtyHandlers', () => { getPathMock.mockReturnValue('/tmp/orca-user-data') existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 }) - openCodeBuildPtyEnvMock.mockReturnValue({ + readFileSyncMock.mockReturnValue('') + openCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingConfigDir?: string) => ({ ORCA_OPENCODE_HOOK_PORT: '4567', ORCA_OPENCODE_HOOK_TOKEN: 'opencode-token', ORCA_OPENCODE_PTY_ID: 'test-pty', - OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config' - }) + OPENCODE_CONFIG_DIR: existingConfigDir + ? '/tmp/orca-opencode-overlay' + : '/tmp/orca-opencode-config' + })) buildAgentHookEnvMock.mockReturnValue({ ORCA_AGENT_HOOK_PORT: '5678', ORCA_AGENT_HOOK_TOKEN: 'agent-token' @@ -181,9 +214,31 @@ describe('registerPtyHandlers', () => { afterEach(() => { unregisterSshPtyProvider('ssh-1') setLocalPtyProvider(new LocalPtyProvider()) - if (savedPiAgentDir !== undefined) { + if (savedPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR + } else { process.env.PI_CODING_AGENT_DIR = savedPiAgentDir } + if (savedOrcaOpenCodeConfigDir === undefined) { + delete process.env.ORCA_OPENCODE_CONFIG_DIR + } else { + process.env.ORCA_OPENCODE_CONFIG_DIR = savedOrcaOpenCodeConfigDir + } + if (savedOrcaOpenCodeSourceConfigDir === undefined) { + delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } else { + process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = savedOrcaOpenCodeSourceConfigDir + } + if (savedOrcaPiAgentDir === undefined) { + delete process.env.ORCA_PI_CODING_AGENT_DIR + } else { + process.env.ORCA_PI_CODING_AGENT_DIR = savedOrcaPiAgentDir + } + if (savedOrcaPiSourceAgentDir === undefined) { + delete process.env.ORCA_PI_SOURCE_AGENT_DIR + } else { + process.env.ORCA_PI_SOURCE_AGENT_DIR = savedOrcaPiSourceAgentDir + } }) function createMockProc() { @@ -336,12 +391,91 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OPENCODE_HOOK_TOKEN).toBe('opencode-token') expect(env.ORCA_OPENCODE_PTY_ID).toBe('test-pty') expect(env.OPENCODE_CONFIG_DIR).toEqual(expect.any(String)) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + }) + + it('mirrors the original OpenCode source dir when launched from an Orca overlay shell', async () => { + const env = await spawnAndGetEnv({ + OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config' + }) + expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/tmp/user-opencode-config' + ) + expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/tmp/user-opencode-config') + }) + + it('reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config', async () => { + // Why: the reporter's app process did not inherit OPENCODE_CONFIG_DIR; + // their interactive zsh startup later exported a company config repo. + readFileSyncMock.mockImplementation((path: string) => { + if (path.endsWith('.zshrc')) { + return [ + '# Company-wide OpenCode config loaded by interactive shells', + 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode-config"', + '' + ].join('\n') + } + return '' + }) + + const env = await spawnAndGetEnv(undefined, { + HOME: '/home/pim', + SHELL: '/bin/zsh', + OPENCODE_CONFIG_DIR: undefined, + ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined + }) + + expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/home/pim/company/opencode-config' + ) + expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/home/pim/company/opencode-config') + expect(env.OPENCODE_CONFIG_DIR).not.toBe(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR) }) it('injects the Pi agent overlay env into Orca terminal PTYs', async () => { const env = await spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }) expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent') expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') + }) + + it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => { + const env = await spawnAndGetEnv({ + PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent' + }) + expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent') + expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') + }) + + it('mirrors Pi config exported only by shell startup files', async () => { + readFileSyncMock.mockImplementation((path: string) => + path.endsWith('.zshrc') ? 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n' : '' + ) + + const env = await spawnAndGetEnv(undefined, { + HOME: '/home/tester', + SHELL: '/bin/zsh', + PI_CODING_AGENT_DIR: undefined + }) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/home/tester/.config/pi-agent' + ) + expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/home/tester/.config/pi-agent') }) it('injects the Claude/Codex hook receiver env into Orca terminal PTYs', async () => { @@ -499,9 +633,31 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567') }) - it('preserves a user-provided OPENCODE_CONFIG_DIR on the daemon path', async () => { + it('mirrors a user-provided OPENCODE_CONFIG_DIR into a per-PTY overlay on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/user/custom/opencode' }) - expect(env.OPENCODE_CONFIG_DIR).toBe('/user/custom/opencode') + // Why: OpenCode loads config from a single dir, so the user's path is + // mirrored into a per-PTY overlay rather than passed through literally. + expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/user/custom/opencode' + ) + expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/user/custom/opencode') + }) + + it('uses source OpenCode config env instead of remirroring a parent overlay', async () => { + const env = await daemonSpawnAndGetEnv({ + OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode' + }) + expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/user/custom/opencode' + ) + expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/user/custom/opencode') }) it('injects Pi overlay env (PI_CODING_AGENT_DIR) on the daemon path', async () => { @@ -511,6 +667,8 @@ describe('registerPtyHandlers', () => { // the fixed overlay path from the shared setup. expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent') expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/user/.pi/agent') }) it('injects the selected Codex home on the daemon path', async () => { @@ -608,6 +766,7 @@ describe('registerPtyHandlers', () => { }) expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/ambient/pi/agent') expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') }) it('skips attribution shims on the daemon path when the setting is disabled', async () => { @@ -762,7 +921,11 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() expect(env.PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() expect(env.CODEX_HOME).toBeUndefined() expect(env.FOO).toBe('bar') expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() @@ -812,7 +975,10 @@ describe('registerPtyHandlers', () => { ) await handlers.get('pty:kill')!(null, { id: 'remote-pty' }) - expect(sshShutdown).toHaveBeenCalledWith('remote-pty', true) + expect(sshShutdown).toHaveBeenCalledWith('remote-pty', { + immediate: true, + keepHistory: false + }) }) it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { @@ -866,6 +1032,70 @@ describe('registerPtyHandlers', () => { ) }) + it('reuses the runtime background handle in local PTY spawn env', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + preAllocatedHandle?: string + }): Promise<{ id: string }> + } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => 'term_wrong'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + expect(controller).not.toBeNull() + const spawnController = controller as unknown as RuntimeSpawnController + await spawnController.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + preAllocatedHandle: 'term_expected' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + const env = spawnCall[2].env as Record + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_expected') + expect(runtime.preAllocateHandleForPty).not.toHaveBeenCalled() + expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith( + expect.any(String), + 'term_expected' + ) + }) + + it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_trusted'), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { ORCA_TERMINAL_HANDLE: 'term_untrusted' } + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + const env = spawnCall[2].env as Record + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_trusted') + expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String)) + }) + describe('Windows UTF-8 code page', () => { let originalPlatform: string let originalComspec: string | undefined @@ -914,11 +1144,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -931,11 +1157,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -994,11 +1216,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'powershell.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -1020,11 +1238,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'powershell.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -1047,11 +1261,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'pwsh.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -1074,11 +1284,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'powershell.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -1101,11 +1307,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'powershell.exe', - [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + ['-NoExit', '-Command', POWERSHELL_PROFILE_COMMAND], expect.any(Object) ) }) @@ -1255,6 +1457,79 @@ describe('registerPtyHandlers', () => { } }) + it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', () => { + const originalPlatform = process.platform + const originalShell = process.env.SHELL + + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'darwin' + }) + process.env.SHELL = '/bin/zsh' + + try { + const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp' }) + expect(shell).toBe('/bin/zsh') + expect(args).toEqual(['-l']) + expect(options.env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') + expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') + expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh') + expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0') + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + } + }) + + it('uses the POSIX shell wrapper so Pi config survives shell startup files', () => { + const originalPlatform = process.platform + const originalShell = process.env.SHELL + + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'darwin' + }) + process.env.SHELL = '/bin/zsh' + openCodeBuildPtyEnvMock.mockImplementationOnce(() => ({ + ORCA_OPENCODE_HOOK_PORT: '4567', + ORCA_OPENCODE_HOOK_TOKEN: 'opencode-token', + ORCA_OPENCODE_PTY_ID: 'test-pty' + })) + + try { + const [shell, args, options] = spawnAndGetCall({ + cwd: '/tmp', + env: { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' } + }) + expect(shell).toBe('/bin/zsh') + expect(args).toEqual(['-l']) + expect(options.env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(options.env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(options.env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') + expect(options.env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') + expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh') + expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0') + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + } + }) + it('does not force ~/.bashrc after sourcing bash login files in the shell-ready rcfile', async () => { const originalPlatform = process.platform const originalShell = process.env.SHELL @@ -1399,7 +1674,14 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( '/bin/zsh', ['-l'], - expect.objectContaining({ cwd: '/tmp' }) + expect.objectContaining({ + cwd: '/tmp', + env: expect.objectContaining({ + ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config', + ORCA_SHELL_READY_MARKER: '0', + ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh' + }) + }) ) expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Shell "/opt/homebrew/bin/bash" is not executable') @@ -1439,7 +1721,12 @@ describe('registerPtyHandlers', () => { ['-l'], expect.objectContaining({ cwd: '/tmp', - env: expect.objectContaining({ SHELL: '/bin/zsh' }) + env: expect.objectContaining({ + SHELL: '/bin/zsh', + ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config', + ORCA_SHELL_READY_MARKER: '0', + ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh' + }) }) ) expect(warnSpy).toHaveBeenCalledWith( @@ -1629,4 +1916,226 @@ describe('registerPtyHandlers', () => { expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id) expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id) }) + + describe('agent_started telemetry', () => { + // Why: telemetry-plan.md§Agent launch semantics — agent_started must + // fire only after provider.spawn resolves. The renderer threads + // launch metadata through `pty:spawn`; a missing or malformed + // payload must not produce a silently-malformed event. + it('emits agent_started after a successful spawn when telemetry is supplied', async () => { + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }) + expect(trackMock).toHaveBeenCalledWith('agent_started', { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + }) + }) + + it('does not emit agent_started when telemetry is omitted (bare-shell tab)', async () => { + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('drops the event when any telemetry field is outside its closed enum', async () => { + handlers.clear() + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + telemetry: { + agent_kind: 'claude-code', + launch_source: 'not_a_real_surface', + request_kind: 'new' + } + }) + expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything()) + }) + + it('does not emit agent_started when provider.spawn throws', async () => { + // Why: telemetry-plan contract is that agent_started fires only on + // confirmed launch. Inject a provider whose spawn throws so we hit + // the catch path with no race against the real LocalPtyProvider. + setLocalPtyProvider({ + spawn: vi.fn(async () => { + throw new Error('spawn boom') + }), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + classifyErrorMock.mockReturnValue({ error_class: 'unknown' }) + handlers.clear() + registerPtyHandlers(mainWindow as never) + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'claude', + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }) + ).rejects.toThrow(/spawn boom/) + expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything()) + }) + }) + + describe('serializeBuffer dispatch', () => { + type SerializeListener = ( + _event: unknown, + args: { + requestId?: string + snapshot?: { data?: unknown; cols?: unknown; rows?: unknown; lastTitle?: unknown } | null + } + ) => void + type SerializeController = { + serializeBuffer: ( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ) => Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + } + + function setup(): { listener: SerializeListener; controller: SerializeController } { + const runtime = { + setPtyController: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + preAllocateHandleForPty: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const onCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:serializeBuffer:response' + ) + if (!onCall) { + throw new Error('expected pty:serializeBuffer:response listener registration') + } + const listener = onCall[1] as SerializeListener + const controller = runtime.setPtyController.mock.calls[0]?.[0] as SerializeController + return { listener, controller } + } + + function getSentRequestIds(): string[] { + return mainWindow.webContents.send.mock.calls + .filter((call: unknown[]) => call[0] === 'pty:serializeBuffer:request') + .map((call: unknown[]) => (call[1] as { requestId: string }).requestId) + } + + it('registers exactly one persistent listener regardless of concurrent in-flight requests', async () => { + const { listener, controller } = setup() + const inflight = [ + controller.serializeBuffer('pty-1'), + controller.serializeBuffer('pty-2'), + controller.serializeBuffer('pty-3'), + controller.serializeBuffer('pty-4'), + controller.serializeBuffer('pty-5'), + controller.serializeBuffer('pty-6'), + controller.serializeBuffer('pty-7'), + controller.serializeBuffer('pty-8'), + controller.serializeBuffer('pty-9'), + controller.serializeBuffer('pty-10'), + controller.serializeBuffer('pty-11'), + controller.serializeBuffer('pty-12') + ] + // Why: the bug being fixed registered one listener per request, so 12 + // concurrent calls would register 12 listeners and trip Node's MaxListeners. + const responseChannelRegistrations = onMock.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:serializeBuffer:response' + ) + expect(responseChannelRegistrations.length).toBe(1) + // Drain the in-flight requests so the test doesn't leak timers. + for (const requestId of getSentRequestIds()) { + listener(null, { requestId, snapshot: null }) + } + await Promise.all(inflight) + }) + + it('routes each response to the originating request via requestId', async () => { + const { listener, controller } = setup() + const a = controller.serializeBuffer('pty-a') + const b = controller.serializeBuffer('pty-b') + const ids = getSentRequestIds() + const requestIdA = ids[0] + const requestIdB = ids[1] + + listener(null, { + requestId: requestIdB, + snapshot: { data: 'B-data', cols: 80, rows: 24 } + }) + listener(null, { + requestId: requestIdA, + snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' } + }) + + await expect(b).resolves.toEqual({ data: 'B-data', cols: 80, rows: 24 }) + await expect(a).resolves.toEqual({ + data: 'A-data', + cols: 100, + rows: 30, + lastTitle: 'A-title' + }) + }) + + it('ignores responses with unknown requestId without affecting pending requests', async () => { + const { listener, controller } = setup() + const pending = controller.serializeBuffer('pty-1') + const realRequestId = getSentRequestIds()[0] + + listener(null, { + requestId: 'not-a-real-id', + snapshot: { data: 'irrelevant', cols: 1, rows: 1 } + }) + listener(null, { requestId: undefined, snapshot: null }) + + let resolved = false + void pending.then(() => { + resolved = true + }) + await new Promise((r) => setTimeout(r, 0)) + expect(resolved).toBe(false) + + listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } }) + await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 }) + }) + + it('resolves to null and removes the entry when the 750ms timeout fires', async () => { + vi.useFakeTimers() + try { + const { controller } = setup() + const pending = controller.serializeBuffer('pty-stuck') + vi.advanceTimersByTime(750) + await expect(pending).resolves.toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('resolves to null when the response snapshot is malformed', async () => { + const { listener, controller } = setup() + const pending = controller.serializeBuffer('pty-bad') + const requestId = getSentRequestIds()[0] + listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) + await expect(pending).resolves.toBeNull() + }) + }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4519c99ac12..52cd206e8d9 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -4,9 +4,11 @@ foreground-process inspection, and renderer IPC stay behind a single audited boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ import { join, delimiter } from 'path' +import { randomUUID } from 'crypto' import { type BrowserWindow, ipcMain, app } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' import { openCodeHookService } from '../opencode/hook-service' import { agentHookServer } from '../agent-hooks/server' @@ -15,6 +17,7 @@ import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' +import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' import { @@ -24,6 +27,15 @@ import { } from '../claude-accounts/live-pty-gate' import { applyTerminalAttributionEnv } from '../attribution/terminal-attribution' import { registerPty, unregisterPty } from '../memory/pty-registry' +import { track } from '../telemetry/client' +import { classifyError } from '../telemetry/classify-error' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../shared/telemetry-events' +import { readShellStartupEnvVar } from '../pty/shell-startup-env' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -36,6 +48,9 @@ const sshProviders = new Map() // post-spawn operations to the correct provider without the renderer needing // to track connectionId per-PTY. const ptyOwnership = new Map() +// Why: mobile clients must mirror desktop PTY geometry even when the renderer +// cannot provide an xterm snapshot yet, such as immediately after tab creation. +const ptySizes = new Map() // Why: the agent-hooks server caches per-paneKey state (last prompt, last // tool) that otherwise grows unbounded as panes come and go. Track the // spawn-time paneKey so clearProviderPtyState can clear that cache on PTY @@ -66,6 +81,45 @@ export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListene return () => paneKeyTeardownListeners.delete(listener) } +// Why: pre-signal handshake — the renderer declares it will own the serializer +// for a paneKey BEFORE issuing pty:spawn. The cooperation gate at provider.spawn +// return consults this map to suppress the daemon-snapshot seed when a renderer +// is taking over. Generation tokens prevent paneKey-reuse races during teardown: +// a paneKeyTeardownListener cleanup only fires settle when the captured gen +// still matches, so a remount that pre-signals before the old PTY's teardown +// runs is preserved. See docs/mobile-prefer-renderer-scrollback.md. +let pendingSerializerGenSeq = 0 +const pendingByPaneKey = new Map() +// Why: at PTY spawn time we capture the gen that was pending for the spawn's +// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey +// remount that replaces the pending entry with a new gen would still get +// stomped by the old PTY's teardown firing settle on the wrong gen. +const ptyPendingGenByPtyId = new Map() +// Why: the runtime's hasRendererSerializer probe needs a ptyId-keyed signal. +// Populated on settlePaneSerializer (renderer has registered for this ptyId) +// and cleared on PTY teardown. +const rendererSerializerByPtyId = new Set() + +function isValidPaneKey(paneKey: unknown): paneKey is string { + return typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256 +} + +function declarePendingPaneSerializer(paneKey: string): number { + const gen = ++pendingSerializerGenSeq + pendingByPaneKey.set(paneKey, gen) + return gen +} + +function settlePendingPaneSerializer(paneKey: string, gen: number): void { + if (pendingByPaneKey.get(paneKey) === gen) { + pendingByPaneKey.delete(paneKey) + } +} + +export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { + return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey) +} + function getProvider(connectionId: string | null | undefined): IPtyProvider { if (!connectionId) { return localProvider @@ -85,6 +139,21 @@ function getProviderForPty(ptyId: string): IPtyProvider { return getProvider(connectionId) } +function normalizeNodePtySpawnError(err: unknown): Error { + const rawMessage = err instanceof Error ? err.message : String(err) + const hintedMessage = addNodePtyRecoveryHint(rawMessage) + if (hintedMessage === rawMessage && err instanceof Error) { + return err + } + if (err instanceof Error) { + // Why: preserve the original stack/name/custom fields while returning the + // same recovery guidance as the renderer-driven pty:spawn path. + err.message = hintedMessage + return err + } + return new Error(hintedMessage) +} + // ─── Host PTY env assembly ────────────────────────────────────────── // Why: both the LocalPtyProvider.buildSpawnEnv closure and the daemon-active // fallback in pty:spawn need the same set of host-local env injections @@ -129,19 +198,44 @@ export function buildPtyHostEnv( // in lock-step across spawn paths without pushing process.env onto the // IPC wire unnecessarily. const preexistingOpenCodeConfigDir = - baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR - const preexistingPiAgentDir = baseEnv.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? + process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? + baseEnv.OPENCODE_CONFIG_DIR ?? + process.env.OPENCODE_CONFIG_DIR ?? + readShellStartupEnvVar( + 'OPENCODE_CONFIG_DIR', + baseEnv.HOME ?? process.env.HOME, + baseEnv.SHELL ?? process.env.SHELL + ) + const preexistingPiAgentDir = + baseEnv.ORCA_PI_SOURCE_AGENT_DIR ?? + process.env.ORCA_PI_SOURCE_AGENT_DIR ?? + baseEnv.PI_CODING_AGENT_DIR ?? + process.env.PI_CODING_AGENT_DIR ?? + readShellStartupEnvVar( + 'PI_CODING_AGENT_DIR', + baseEnv.HOME ?? process.env.HOME, + baseEnv.SHELL ?? process.env.SHELL + ) - const openCodeHookEnv = openCodeHookService.buildPtyEnv(id) - if (preexistingOpenCodeConfigDir) { - // Why: OPENCODE_CONFIG_DIR is a singular extra config root. Replacing a - // user-provided directory would silently hide their custom OpenCode - // config, so preserve it. The Orca status plugin will not load, so the - // dashboard falls back to a blank status for that pane until the user - // unsets their override. - delete openCodeHookEnv.OPENCODE_CONFIG_DIR + // Why: OPENCODE_CONFIG_DIR is a singular path, not a colon-list, so a user + // value cannot coexist with an Orca-only injection. Hand the user's value + // (when present) to the hook service and let it materialize a per-PTY + // mirror overlay that lets the user's plugins and Orca's status plugin + // load together — same pattern Pi uses below for PI_CODING_AGENT_DIR. See + // docs/opencode-config-dir-collision.md. + Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + if (baseEnv.OPENCODE_CONFIG_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready + // wrappers restore this PTY-scoped value after user startup files run. + baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR + if (preexistingOpenCodeConfigDir) { + // Why: terminals launched from another Orca terminal inherit the overlay + // as OPENCODE_CONFIG_DIR; keep the original source so overlays do not + // mirror overlays and drop the user's real config. + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir + } } - Object.assign(baseEnv, openCodeHookEnv) // Why: Claude/Codex native hooks run inside the shell process, so Orca // must inject the loopback receiver coordinates before the agent starts. @@ -158,6 +252,16 @@ export function buildPtyHostEnv( // back to a fresh UUID per spawn; that would discard user Pi state on // every daemon reconnect. Object.assign(baseEnv, piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir)) + if (baseEnv.PI_CODING_AGENT_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready + // wrappers restore this PTY-scoped value after user startup files run. + baseEnv.ORCA_PI_CODING_AGENT_DIR = baseEnv.PI_CODING_AGENT_DIR + if (preexistingPiAgentDir) { + // Why: preserve the original Pi root across nested Orca terminals; the + // public env var is intentionally restored to the current PTY overlay. + baseEnv.ORCA_PI_SOURCE_AGENT_DIR = preexistingPiAgentDir + } + } // Why: Codex account switching now materializes auth into one shared // runtime home (~/.codex), and Codex launched inside Orca terminals must @@ -284,10 +388,12 @@ export function clearProviderPtyState(id: string): void { // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) + ptySizes.delete(id) // Why: drop the memory-collector registration so a dead PTY does not keep // trying to resolve its (now-dead) pid on every snapshot. Safe no-op for // PTYs that were never registered (SSH-owned). unregisterPty(id) + rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to @@ -297,6 +403,17 @@ export function clearProviderPtyState(id: string): void { agentHookServer.clearPaneState(paneKey) ptyPaneKey.delete(id) paneKeyPtyId.delete(paneKey) + // Why: drop the pre-signal pending entry only if it still belongs to THIS + // PTY's spawn generation. If a remount for the same paneKey has already + // pre-signaled a new gen, this teardown must NOT touch it — otherwise + // the second mount's hydration loses to the daemon-snapshot seed. See + // the generation-token rationale in + // docs/mobile-prefer-renderer-scrollback.md. + const ownedGen = ptyPendingGenByPtyId.get(id) + if (ownedGen !== undefined) { + settlePendingPaneSerializer(paneKey, ownedGen) + } + ptyPendingGenByPtyId.delete(id) // Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId // entries so a listener that re-reads the map sees the post-teardown // state. Wrap each call so one throwing listener cannot block the rest. @@ -353,7 +470,8 @@ export function registerPtyHandlers( runtime?: OrcaRuntimeService, getSelectedCodexHomePath?: () => string | null, getSettings?: () => GlobalSettings, - prepareClaudeAuth?: () => Promise + prepareClaudeAuth?: () => Promise, + store?: Store ): void { // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). @@ -363,8 +481,12 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') ipcMain.removeHandler('pty:getCwd') + ipcMain.removeHandler('pty:declarePendingPaneSerializer') + ipcMain.removeHandler('pty:settlePaneSerializer') + ipcMain.removeHandler('pty:clearPendingPaneSerializer') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') + ipcMain.removeAllListeners('pty:serializeBuffer:response') // Configure the local provider with app-specific hooks. // Why: only LocalPtyProvider has the configure() method — daemon-backed @@ -389,7 +511,14 @@ export function registerPtyHandlers( }) // Why: agents need their own terminal handle at process start so they // can self-identify in orchestration messages without an extra RPC. - const preAllocatedHandle = runtime?.preAllocateHandleForPty(id) + const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE + const preAllocatedHandle = + requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) + ? requestedHandle + : runtime?.preAllocateHandleForPty(id) + if (requestedHandle && requestedHandle !== preAllocatedHandle) { + delete env.ORCA_TERMINAL_HANDLE + } if (preAllocatedHandle) { env.ORCA_TERMINAL_HANDLE = preAllocatedHandle } @@ -410,6 +539,7 @@ export function registerPtyHandlers( // reduces IPC round-trips from hundreds/sec to ~120/sec under high // throughput, with no perceptible latency increase for interactive use. const pendingData = new Map() + const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null const PTY_BATCH_INTERVAL_MS = 8 @@ -436,10 +566,15 @@ export function registerPtyHandlers( // Why: LocalPtyProvider routes data to the runtime via configure().onData, // but daemon-backed providers don't have configure(). Without this, daemon // PTY data never reaches the runtime's tail buffer, so terminal.read returns - // empty and agent-detection from raw data never fires. + // empty and agent-detection from raw data never fires. Runtime tails also + // power mobile read/stream, so they must be notified regardless of window + // state. const isLocalProvider = localProvider instanceof LocalPtyProvider localDataUnsub = localProvider.onData((payload) => { + if (!isLocalProvider) { + runtime?.onPtyData(payload.id, payload.data, Date.now()) + } if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window // is gone. Without this, macOS app re-activation leaks orphaned timers @@ -451,9 +586,6 @@ export function registerPtyHandlers( pendingData.clear() return } - if (!isLocalProvider) { - runtime?.onPtyData(payload.id, payload.data, Date.now()) - } const existing = pendingData.get(payload.id) pendingData.set(payload.id, existing ? existing + payload.data : payload.data) if (!flushTimer) { @@ -484,6 +616,92 @@ export function registerPtyHandlers( bindProviderListeners() rebindProviderListeners = bindProviderListeners + // Why: a persistent ipcMain listener with a request-ID dispatch table + // (instead of one listener per call) so concurrent serialize requests do + // not stack listeners and trip Node's MaxListeners=10 warning. Many + // sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+ + // concurrent calls. + type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null + const pendingSerializeRequests = new Map< + string, + { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } + >() + + function settleSerializeRequest(requestId: string, result: SerializeResult): void { + const pending = pendingSerializeRequests.get(requestId) + if (!pending) { + return + } + clearTimeout(pending.timeout) + pendingSerializeRequests.delete(requestId) + pending.resolve(result) + } + + ipcMain.on( + 'pty:serializeBuffer:response', + ( + _event, + args: { + requestId?: string + snapshot?: { + data?: unknown + cols?: unknown + rows?: unknown + lastTitle?: unknown + } | null + } + ) => { + if (typeof args?.requestId !== 'string') { + return + } + const snapshot = args.snapshot + if ( + snapshot && + typeof snapshot.data === 'string' && + typeof snapshot.cols === 'number' && + typeof snapshot.rows === 'number' + ) { + const result: { data: string; cols: number; rows: number; lastTitle?: string } = { + data: snapshot.data, + cols: snapshot.cols, + rows: snapshot.rows + } + if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { + result.lastTitle = snapshot.lastTitle + } + settleSerializeRequest(args.requestId, result) + } else { + settleSerializeRequest(args.requestId, null) + } + } + ) + + function requestSerializedBuffer( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ): Promise { + if (mainWindow.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + return new Promise((resolve) => { + const timeout = setTimeout(() => { + settleSerializeRequest(requestId, null) + }, 750) + pendingSerializeRequests.set(requestId, { resolve, timeout }) + const payload: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } = { requestId, ptyId } + if (opts) { + payload.opts = opts + } + mainWindow.webContents.send('pty:serializeBuffer:request', payload) + }) + } + // Kill orphaned PTY processes from previous page loads when the renderer reloads. // Why: only applies to LocalPtyProvider where PTYs live in the Electron main // process and can become orphaned on page reload. Daemon-backed sessions @@ -509,6 +727,110 @@ export function registerPtyHandlers( // CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs. // Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs. runtime?.setPtyController({ + spawn: async (args) => { + const provider = getProvider(args.connectionId) + const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) + if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + + const isDaemonHostSpawn = !args.connectionId && !(provider instanceof LocalPtyProvider) + const sessionId = isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined + let env: Record | undefined = claudeAuth + ? { ...args.env, ...claudeAuth.envPatch } + : args.env + if (args.preAllocatedHandle) { + env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } + } + if (isDaemonHostSpawn && sessionId) { + if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + env = buildPtyHostEnv(sessionId, env ?? {}, { + isPackaged: app.isPackaged, + userDataPath: app.getPath('userData'), + selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false + }) + } + + const spawnOptions: PtySpawnOptions = { + cols: args.cols, + rows: args.rows, + cwd: args.cwd, + env + } + if (claudeAuth?.stripAuthEnv) { + spawnOptions.envToDelete = [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + } + if (args.command !== undefined) { + spawnOptions.command = args.command + } + if (args.worktreeId !== undefined) { + spawnOptions.worktreeId = args.worktreeId + } + if (sessionId !== undefined) { + spawnOptions.sessionId = sessionId + ptySizes.set(sessionId, { cols: args.cols, rows: args.rows }) + } + if (process.platform === 'win32' && !args.connectionId) { + spawnOptions.shellOverride = getSettings?.()?.terminalWindowsShell + spawnOptions.terminalWindowsPowerShellImplementation = getSettings + ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + + let result: PtySpawnResult + try { + if (args.preAllocatedHandle) { + trustedTerminalHandleEnv.add(args.preAllocatedHandle) + } + result = await provider.spawn(spawnOptions) + } catch (err) { + if (sessionId !== undefined) { + ptySizes.delete(sessionId) + clearProviderPtyState(sessionId) + } + throw normalizeNodePtySpawnError(err) + } finally { + if (args.preAllocatedHandle) { + trustedTerminalHandleEnv.delete(args.preAllocatedHandle) + } + } + ptyOwnership.set(result.id, args.connectionId ?? null) + ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) + if (args.preAllocatedHandle) { + runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle) + } + if (args.worktreeId) { + runtime?.registerPty(result.id, args.worktreeId) + } + if (isClaudeLaunch) { + markClaudePtySpawned(result.id) + } + if (!args.connectionId) { + registerPty({ + ptyId: result.id, + worktreeId: args.worktreeId ?? null, + sessionId: sessionId ?? null, + paneKey: null, + pid: + typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0 + ? result.pid + : null + }) + } + return { id: result.id } + }, write: (ptyId, data) => { const provider = getProviderForPty(ptyId) try { @@ -523,7 +845,7 @@ export function registerPtyHandlers( // Why: shutdown() is async but the PtyController interface is sync. // Swallowing the rejection prevents an unhandled promise rejection crash // if the remote SSH session is already gone. - void provider.shutdown(ptyId, false).catch(() => {}) + void provider.shutdown(ptyId, { immediate: false }).catch(() => {}) clearProviderPtyState(ptyId) markClaudePtyExited(ptyId) runtime?.onPtyExit(ptyId, -1) @@ -535,6 +857,36 @@ export function registerPtyHandlers( } catch { return null } + }, + listProcesses: async () => { + const providerSessions = await Promise.all([ + localProvider.listProcesses(), + ...Array.from(sshProviders.values(), (provider) => provider.listProcesses().catch(() => [])) + ]) + return providerSessions.flat() + }, + serializeBuffer: (ptyId, opts) => { + // Why: mobile xterm must start from the desktop xterm's exact screen + // state and dimensions before live TUI chunks can render correctly. + return requestSerializedBuffer(ptyId, opts) + }, + hasRendererSerializer: (ptyId) => { + // Why: the runtime needs a synchronous probe so it can decide whether to + // skip the daemon-snapshot seed (the renderer will hydrate it) or run the + // seed (no renderer authoritative for this PTY). A registry write happens + // when the renderer calls registerPtySerializer; we check via the same + // pendingByPaneKey + ptyId pairing that the cooperation gate uses. + return rendererSerializerByPtyId.has(ptyId) + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null, + resize: (ptyId, cols, rows) => { + try { + ptySizes.set(ptyId, { cols, rows }) + getProviderForPty(ptyId).resize(ptyId, cols, rows) + return true + } catch { + return false + } } }) @@ -554,6 +906,25 @@ export function registerPtyHandlers( worktreeId?: string sessionId?: string shellOverride?: string + // Why: closes the SIGKILL race documented in INVESTIGATION.md by + // letting main patch + sync-flush the (worktreeId, tabId, leafId → + // ptyId) binding before pty:spawn returns. Only the renderer's + // user-typing-Ctrl+T daemon-host path threads these; mobile/runtime + // CLI/SSH spawns leave them undefined and the main-side guard + // short-circuits. + tabId?: string + leafId?: string + // Why: telemetry-plan.md§Agent launch semantics. The renderer + // threads what Orca was *asked* to launch through this field; main + // fires `agent_started` only after `provider.spawn` resolves. Loose + // typing on the IPC boundary because the main-side schema + // validator is the single enforcement point — `track()` will drop + // the event if any field is outside its closed enum. + telemetry?: { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + } } ) => { const provider = getProvider(args.connectionId) @@ -691,6 +1062,12 @@ export function registerPtyHandlers( if (effectiveShellOverride !== undefined) { spawnOptions.shellOverride = effectiveShellOverride } + if (effectiveSessionId !== undefined) { + // Why: daemon PTYs can emit prompt/startup bytes before spawn() + // resolves. Runtime headless snapshots need the real pane geometry + // for those early bytes; otherwise they default to 80x24 and wrap TUIs. + ptySizes.set(effectiveSessionId, { cols: args.cols, rows: args.rows }) + } if (process.platform === 'win32' && !args.connectionId) { // Why: the renderer only models PowerShell as one shell family. Thread // the persisted implementation choice through spawnOptions so both the @@ -702,27 +1079,131 @@ export function registerPtyHandlers( } let result: PtySpawnResult try { + if (preAllocatedHandle) { + trustedTerminalHandleEnv.add(preAllocatedHandle) + } result = await provider.spawn(spawnOptions) } catch (err) { + const spawnError = normalizeNodePtySpawnError(err) + if (effectiveSessionId !== undefined) { + ptySizes.delete(effectiveSessionId) + } // Why: when buildPtyHostEnv materialized a Pi overlay for this id - // but provider.spawn failed, the overlay would leak. Sweep per-PTY - // state for the minted id so it isn't orphaned. Safe to call even - // when no overlay was created (clearProviderPtyState is a no-op in - // that case). - // - // Only clean up when we MINTED the id in this request. Caller-supplied - // ids may correspond to existing PTYs whose state (OpenCode hooks, Pi - // overlay, agent-hook pane caches) we MUST NOT clear on a retry/attach - // failure. + // but provider.spawn failed, the overlay would leak. if (isMintedSessionId && effectiveSessionId !== undefined) { clearProviderPtyState(effectiveSessionId) } - throw err + // Why: telemetry-plan.md§agent_error — when the renderer threaded + // agent_kind through args.telemetry, attribute the error to that agent. + // Otherwise fall back to sniffing the command for `claude` (the one + // agent the main process can identify on its own via the existing + // `isClaudeLaunchCommand` regex used for auth gating). Bare-shell + // catches and unknown-agent catches without renderer telemetry remain + // unattributed. The event still emits with a classified `error_class`; + // raw error messages are dropped at the telemetry validator boundary. + const rendererAgentKindParse = + args.telemetry?.agent_kind !== undefined + ? agentKindSchema.safeParse(args.telemetry.agent_kind) + : null + const errorAgentKind = rendererAgentKindParse?.success + ? rendererAgentKindParse.data + : isClaudeLaunch + ? ('claude-code' as const) + : null + if (errorAgentKind) { + const classified = classifyError(spawnError) + track('agent_error', { + agent_kind: errorAgentKind, + error_class: classified.error_class, + ...getCohortAtEmit() + }) + } + throw spawnError + } finally { + if (preAllocatedHandle) { + trustedTerminalHandleEnv.delete(preAllocatedHandle) + } } ptyOwnership.set(result.id, args.connectionId ?? null) if (preAllocatedHandle) { runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) } + ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) + // Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217). + // The renderer's debounced session writer runs in parallel for every + // other field; we patch the load-bearing (tab.ptyId, ptyIdsByLeafId) + // binding synchronously so a force-quit in the ~450 ms debounce window + // can no longer orphan the daemon's history dir. Other spawn callers + // (mobile, runtime CLI, SSH) leave tabId/leafId undefined and this + // short-circuits — preserves their existing behavior. + if ( + isDaemonHostSpawn && + store && + args.worktreeId !== undefined && + args.tabId !== undefined && + args.leafId !== undefined + ) { + store.persistPtyBinding({ + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId, + ptyId: result.id + }) + } + // Why: pre-signal cooperation gate — when the renderer has declared it + // will own the serializer for this paneKey, suppress the daemon-snapshot + // seed so the renderer's hydration path (maybeHydrateHeadlessFromRenderer) + // is the sole authority. The pre-signal is keyed on paneKey because at + // spawn time the renderer doesn't yet know the new ptyId. See + // docs/mobile-prefer-renderer-scrollback.md. + const spawnPaneKey = args.env?.ORCA_PANE_KEY + const rendererPreSignaled = isValidPaneKey(spawnPaneKey) + ? pendingByPaneKey.has(spawnPaneKey) + : false + const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) + // Why: capture the pending gen at spawn time so teardown for THIS PTY + // only settles its own generation. A remount that replaces the entry + // with a new gen must not be stomped by the old PTY's teardown. + if (isValidPaneKey(spawnPaneKey) && rendererPreSignaled) { + const gen = pendingByPaneKey.get(spawnPaneKey) + if (gen !== undefined) { + ptyPendingGenByPtyId.set(result.id, gen) + } + } + + // Why: hydrate the runtime's headless emulator with the adapter's + // restore data BEFORE registerPty so any live PTY data that arrives + // concurrently lands on top of the seed instead of replacing it. Mobile + // subscribers then see the same scrollback the desktop xterm received + // via coldRestore/snapshot. Without this, mobile snapshots after a + // daemon-restored attach contain only bytes emitted since the relaunch + // and the prior agent output silently disappears. + // + // Skip when the renderer is or will be authoritative for this PTY: + // its hydration path will seed the emulator from xterm's live buffer, + // which is richer than the daemon snapshot. + if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { + const seedSize = + typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' + ? { cols: result.snapshotCols, rows: result.snapshotRows } + : undefined + if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { + runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize) + } else if ( + result.coldRestore && + typeof result.coldRestore.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ) { + runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize) + } + } + if ( + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ) { + runtime?.registerPty(result.id, args.worktreeId) + } if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -776,11 +1257,43 @@ export function registerPtyHandlers( : null }) } + // Why: telemetry-plan.md§Agent launch semantics — fire `agent_started` + // only after `provider.spawn` resolved. The renderer threads + // `args.telemetry` through the spawn IPC for every launch we want to + // attribute; bare-shell tabs (no agent) leave the field undefined and + // do not produce an event. Each field is parsed against its closed + // enum here so a malformed renderer payload (or a spoofed IPC) does + // not poison the event — `safeParse` failure drops that field, and + // if any required field is missing we skip the event entirely. The + // main-side `track()` validator re-runs the schema on the full + // payload as a second defense-in-depth check. + if (args.telemetry) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + ...getCohortAtEmit() + }) + } + } return result } ) ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => { + // Why: defense-in-depth for the mobile-presence lock. The renderer's + // xterm.onData guard already drops desktop keystrokes when mobile is + // driving, but a stale view between the main-side state flip and the + // IPC arriving in the renderer can let one keystroke slip through. + // This server-side check catches it. See + // docs/mobile-presence-lock.md. + if (runtime?.getDriver(args.id).kind === 'mobile') { + return + } getProviderForPty(args.id).write(args.id, args.data) }) @@ -789,7 +1302,44 @@ export function registerPtyHandlers( // empty acknowledgement message back to the renderer. ipcMain.removeAllListeners('pty:resize') ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + // Why: after a desktop-fit override change, the desktop renderer's + // re-render cascade runs safeFit on ALL panes (not just the affected + // one). Background-tab panes get measured at full-width (214) instead + // of their correct split width. Suppressing ALL pty:resize during + // this window prevents the cascade from corrupting PTY dimensions. + if (runtime?.isResizeSuppressed()) { + return + } + // Why: presence-lock defense-in-depth. While mobile is driving, + // desktop-side resizes (auto-fit on window resize, split drag) must + // not reach the PTY. The renderer guard checks the driver state too, + // but this is the load-bearing layer because the renderer mirror lags + // by one IPC hop. Note: BOTH guards apply — isResizeSuppressed handles + // the safeFit cascade after take-back; this driver check handles the + // ongoing locked state. See docs/mobile-presence-lock.md. + if (runtime?.getDriver(args.id).kind === 'mobile') { + return + } + ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) getProviderForPty(args.id).resize(args.id, args.cols, args.rows) + runtime?.onExternalPtyResize(args.id, args.cols, args.rows) + }) + + // Why: pty:reportGeometry is a measurement-only sibling of pty:resize. + // pty:resize means "I want the PTY at this size" (a write/intent — gated + // by mobile-driver and cascade suppress). pty:reportGeometry means "the + // desktop pane I'm rendering currently measures this many cells" (a + // read/observation). Mobile-fit hold needs the latter even while the + // former is intentionally blocked: when a previously-hidden desktop + // tab becomes visible while a phone is driving, the server has no way + // to learn the real desktop dims, and resolveDesktopRestoreTarget + // returns the stale spawn default (e.g. 80×24) on Take Back. Splitting + // the channels keeps each guard simple — pty:resize keeps its mobile- + // driver gate; pty:reportGeometry never resizes the PTY, only refreshes + // the restore-target cache. See docs/mobile-fit-hold.md. + ipcMain.removeAllListeners('pty:reportGeometry') + ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { + runtime?.recordRendererGeometry(args.id, args.cols, args.rows) }) // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore @@ -808,13 +1358,16 @@ export function registerPtyHandlers( .catch(() => {}) }) - ipcMain.handle('pty:kill', async (_event, args: { id: string }) => { + ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { // Why: try/finally ensures ptyOwnership is cleaned up even if shutdown // throws (e.g. SSH connection already gone or daemon session already // reaped). Swallowing the error prevents noisy renderer-side rejections // when killing orphaned sessions that the daemon has already discarded. try { - await getProviderForPty(args.id).shutdown(args.id, true) + await getProviderForPty(args.id).shutdown(args.id, { + immediate: true, + keepHistory: args.keepHistory ?? false + }) } catch { /* session already dead — cleanup below handles the rest */ } finally { @@ -884,6 +1437,51 @@ export function registerPtyHandlers( return '' } }) + + // Why: pre-signal handshake handlers. See + // docs/mobile-prefer-renderer-scrollback.md and the rationale on + // `pendingByPaneKey` above. The IPC contract is: renderer awaits declare + // (capturing the returned gen), awaits pty:spawn, then registers its + // serializer locally and calls settle (echoing the gen). On spawn rejection + // or pane unmount before settle, renderer calls clear with the same gen. + ipcMain.handle( + 'pty:declarePendingPaneSerializer', + async (_event, args: { paneKey?: unknown }): Promise => { + if (!isValidPaneKey(args.paneKey)) { + throw new Error('Invalid paneKey') + } + return declarePendingPaneSerializer(args.paneKey) + } + ) + + ipcMain.handle( + 'pty:settlePaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { + return + } + settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: settle means the renderer has registered its serializer locally + // for whatever ptyId came back from spawn. The renderer doesn't carry + // the ptyId back through this IPC because the cooperation gate ran + // pre-spawn; instead we mark the pane as authoritative by paneKey → + // ptyId via the existing paneKeyPtyId mapping populated at spawn. + const ptyId = paneKeyPtyId.get(args.paneKey) + if (ptyId) { + rendererSerializerByPtyId.add(ptyId) + } + } + ) + + ipcMain.handle( + 'pty:clearPendingPaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { + return + } + settlePendingPaneSerializer(args.paneKey, args.gen) + } + ) } /** diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 916c8e97859..76d422abfeb 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -11,16 +11,18 @@ const { registerMemoryHandlersMock, registerNotificationHandlersMock, registerDeveloperPermissionHandlersMock, + registerComputerUsePermissionHandlersMock, registerSettingsHandlersMock, registerTelemetryHandlersMock, registerShellHandlersMock, - registerSidekickHandlersMock, + registerPetHandlersMock, registerSessionHandlersMock, registerUIHandlersMock, registerFilesystemHandlersMock, registerRuntimeHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, + registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerClipboardHandlersMock, registerUpdaterHandlersMock, @@ -31,7 +33,8 @@ const { registerFilesystemWatcherHandlersMock, registerAppHandlersMock, registerLinearHandlersMock, - registerExportHandlersMock + registerExportHandlersMock, + registerOnboardingHandlersMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), @@ -43,16 +46,18 @@ const { registerMemoryHandlersMock: vi.fn(), registerNotificationHandlersMock: vi.fn(), registerDeveloperPermissionHandlersMock: vi.fn(), + registerComputerUsePermissionHandlersMock: vi.fn(), registerSettingsHandlersMock: vi.fn(), registerTelemetryHandlersMock: vi.fn(), registerShellHandlersMock: vi.fn(), - registerSidekickHandlersMock: vi.fn(), + registerPetHandlersMock: vi.fn(), registerSessionHandlersMock: vi.fn(), registerUIHandlersMock: vi.fn(), registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), + registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -63,7 +68,12 @@ const { registerFilesystemWatcherHandlersMock: vi.fn(), registerAppHandlersMock: vi.fn(), registerLinearHandlersMock: vi.fn(), - registerExportHandlersMock: vi.fn() + registerExportHandlersMock: vi.fn(), + registerOnboardingHandlersMock: vi.fn() +})) + +vi.mock('./onboarding', () => ({ + registerOnboardingHandlers: registerOnboardingHandlersMock })) vi.mock('./cli', () => ({ @@ -110,6 +120,10 @@ vi.mock('./developer-permissions', () => ({ registerDeveloperPermissionHandlers: registerDeveloperPermissionHandlersMock })) +vi.mock('./computer-use-permissions', () => ({ + registerComputerUsePermissionHandlers: registerComputerUsePermissionHandlersMock +})) + vi.mock('./settings', () => ({ registerSettingsHandlers: registerSettingsHandlersMock })) @@ -122,8 +136,8 @@ vi.mock('./shell', () => ({ registerShellHandlers: registerShellHandlersMock })) -vi.mock('./sidekick', () => ({ - registerSidekickHandlers: registerSidekickHandlersMock +vi.mock('./pet', () => ({ + registerPetHandlers: registerPetHandlersMock })) vi.mock('./session', () => ({ @@ -158,6 +172,10 @@ vi.mock('./agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) +vi.mock('./agent-trust', () => ({ + registerAgentTrustHandlers: registerAgentTrustHandlersMock +})) + vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) @@ -195,16 +213,18 @@ describe('registerCoreHandlers', () => { registerMemoryHandlersMock.mockReset() registerNotificationHandlersMock.mockReset() registerDeveloperPermissionHandlersMock.mockReset() + registerComputerUsePermissionHandlersMock.mockReset() registerSettingsHandlersMock.mockReset() registerTelemetryHandlersMock.mockReset() registerShellHandlersMock.mockReset() - registerSidekickHandlersMock.mockReset() + registerPetHandlersMock.mockReset() registerSessionHandlersMock.mockReset() registerUIHandlersMock.mockReset() registerFilesystemHandlersMock.mockReset() registerRuntimeHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() + registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() registerUpdaterHandlersMock.mockReset() @@ -243,7 +263,7 @@ describe('registerCoreHandlers', () => { expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) expect(registerAgentHookHandlersMock).toHaveBeenCalled() - expect(registerSidekickHandlersMock).toHaveBeenCalled() + expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) @@ -251,9 +271,11 @@ describe('registerCoreHandlers', () => { expect(registerFeedbackHandlersMock).toHaveBeenCalled() expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats) expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store) - expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store) + expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime) expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled() + expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled() expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store) + expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) expect(registerUIHandlersMock).toHaveBeenCalledWith(store) expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 704358fc214..cf0a7467d11 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -17,7 +17,9 @@ import { registerMemoryHandlers } from './memory' import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' import { registerNotificationHandlers } from './notifications' +import { registerOnboardingHandlers } from './onboarding' import { registerDeveloperPermissionHandlers } from './developer-permissions' +import { registerComputerUsePermissionHandlers } from './computer-use-permissions' import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' @@ -25,10 +27,11 @@ import { registerTelemetryHandlers } from './telemetry' import { registerBrowserHandlers } from './browser' import { browserSessionRegistry } from '../browser/browser-session-registry' import { registerShellHandlers } from './shell' -import { registerSidekickHandlers } from './sidekick' +import { registerPetHandlers } from './pet' import { registerUIHandlers } from './ui' import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' +import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' import { warmSystemFontFamilies } from '../system-fonts' import { @@ -72,6 +75,7 @@ export function registerCoreHandlers( registerCodexUsageHandlers(codexUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers() + registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) @@ -80,10 +84,12 @@ export function registerCoreHandlers( registerExportHandlers() registerStatsHandlers(stats) registerMemoryHandlers(store) - registerNotificationHandlers(store) + registerNotificationHandlers(store, runtime) + registerOnboardingHandlers(store) registerDeveloperPermissionHandlers() + registerComputerUsePermissionHandlers() registerSettingsHandlers(store) - registerTelemetryHandlers() + registerTelemetryHandlers(store) registerBrowserHandlers() // Why: applyPendingCookieImport MUST run before restorePersistedUserAgent // because the latter calls session.fromPartition() which initializes @@ -92,7 +98,7 @@ export function registerCoreHandlers( browserSessionRegistry.applyPendingCookieImport() browserSessionRegistry.restorePersistedUserAgent() registerShellHandlers() - registerSidekickHandlers() + registerPetHandlers() registerSessionHandlers(store) registerUIHandlers(store) registerFilesystemHandlers(store) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts new file mode 100644 index 00000000000..ef5b78197f8 --- /dev/null +++ b/src/main/ipc/repos-create.test.ts @@ -0,0 +1,379 @@ +/** + * Unit tests for repos:create (orca#763). + * + * Pins the invariants that matter here: + * - Name validation catches empty/slash/./.. before any fs I/O. + * - Empty pre-existing directories are accepted; non-empty ones are not. + * - Only directories we create ourselves are removed on rollback — a folder + * the user picked must survive a failure so they can retry. + * - Git repos get an empty initial commit; without it, HEAD has no branch. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { + handleMock, + removeHandlerMock, + mockStore, + mkdirMock, + accessMock, + readdirMock, + rmMock, + gitExecFileAsyncMock, + invalidateAuthorizedRootsCacheMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeRepo: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn() + }, + mkdirMock: vi.fn(), + accessMock: vi.fn(), + readdirMock: vi.fn(), + rmMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + invalidateAuthorizedRootsCacheMock: vi.fn() +})) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { + handle: handleMock, + removeHandler: removeHandlerMock + } +})) + +vi.mock('fs/promises', () => ({ + mkdir: mkdirMock, + access: accessMock, + readdir: readdirMock, + rm: rmMock +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitSpawn: vi.fn() +})) + +vi.mock('../git/repo', () => ({ + isGitRepo: vi.fn().mockReturnValue(true), + getGitUsername: vi.fn().mockReturnValue(''), + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), + searchBaseRefs: vi.fn().mockResolvedValue([]) +})) + +vi.mock('./filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: vi.fn() +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: vi.fn() +})) + +import { registerRepoHandlers } from './repos' + +type CreateArgs = { parentPath: string; name: string; kind: 'git' | 'folder' } +type CreateResult = + | { repo: { id: string; path: string; kind: 'git' | 'folder' } } + | { error: string } + +describe('repos:create', () => { + const handlers = new Map Promise>() + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + const callCreate = (args: CreateArgs): Promise => { + const handler = handlers.get('repos:create') + if (!handler) { + throw new Error('repos:create handler was never registered') + } + return handler(null, args) + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler as (event: unknown, args: CreateArgs) => Promise) + }) + removeHandlerMock.mockReset() + mockStore.getRepos.mockReset().mockReturnValue([]) + mockStore.addRepo.mockReset() + mockWindow.webContents.send.mockReset() + invalidateAuthorizedRootsCacheMock.mockReset() + + // Default baseline: target does NOT exist yet, mkdir succeeds, git OK. + accessMock.mockReset().mockRejectedValue(new Error('ENOENT')) + readdirMock.mockReset().mockResolvedValue([]) + mkdirMock.mockReset().mockResolvedValue(undefined) + rmMock.mockReset().mockResolvedValue(undefined) + gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + + registerRepoHandlers(mockWindow as never, mockStore as never) + }) + + it('registers the repos:create handler', () => { + expect(handlers.has('repos:create')).toBe(true) + }) + + it('unregisters any previously-registered repos:create handler', () => { + // registerRepoHandlers must call removeHandler('repos:create') before + // ipcMain.handle to avoid the "second handler for same channel" throw + // when this module is re-registered (e.g., after a reload). + expect(removeHandlerMock).toHaveBeenCalledWith('repos:create') + }) + + // ── input validation ────────────────────────────────────────────── + + it('rejects empty names', async () => { + const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' }) + expect(result).toEqual({ error: 'Name cannot be empty' }) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + it('rejects names containing a forward slash', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'foo/bar', kind: 'git' }) + expect(result).toMatchObject({ error: expect.stringContaining('slash') }) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + it('rejects names containing a backslash', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'foo\\bar', kind: 'git' }) + expect(result).toMatchObject({ error: expect.stringContaining('slash') }) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + it('rejects "." and ".." as names', async () => { + for (const name of ['.', '..']) { + mkdirMock.mockClear() + const result = await callCreate({ parentPath: '/tmp', name, kind: 'git' }) + expect(result).toMatchObject({ error: expect.stringContaining('slash') }) + expect(mkdirMock).not.toHaveBeenCalled() + } + }) + + it('rejects empty parent path', async () => { + const result = await callCreate({ parentPath: ' ', name: 'project', kind: 'git' }) + expect(result).toEqual({ error: 'Parent directory is required' }) + expect(mkdirMock).not.toHaveBeenCalled() + }) + + // ── existing-directory handling ─────────────────────────────────── + + it('rejects a non-empty existing directory without calling mkdir', async () => { + accessMock.mockResolvedValueOnce(undefined) // exists + readdirMock.mockResolvedValueOnce(['README.md', '.DS_Store']) + + const result = await callCreate({ parentPath: '/tmp', name: 'busy', kind: 'git' }) + + expect(result).toMatchObject({ error: expect.stringContaining('not empty') }) + expect(mkdirMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('accepts an empty existing directory and does not call mkdir', async () => { + accessMock.mockResolvedValueOnce(undefined) // exists + readdirMock.mockResolvedValueOnce([]) + + const result = await callCreate({ parentPath: '/tmp', name: 'empty', kind: 'folder' }) + + expect(mkdirMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ path: '/tmp/empty', kind: 'folder' }) + ) + expect(result).toHaveProperty('repo.kind', 'folder') + }) + + it('creates a missing directory with mkdir', async () => { + // accessMock rejects by default → path does not exist + await callCreate({ parentPath: '/tmp', name: 'brand-new', kind: 'folder' }) + + expect(mkdirMock).toHaveBeenCalledWith('/tmp/brand-new', { recursive: false }) + }) + + // ── plain folder happy path ─────────────────────────────────────── + + it('creates a plain folder without running any git commands', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'plain', kind: 'folder' }) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/plain', + displayName: 'plain', + kind: 'folder' + }) + ) + expect(result).toHaveProperty('repo.kind', 'folder') + }) + + // ── git repo happy path ─────────────────────────────────────────── + + it('creates a git repo with an empty initial commit (in order)', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'gitproj', kind: 'git' }) + + expect(mkdirMock).toHaveBeenCalledWith('/tmp/gitproj', { recursive: false }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { cwd: '/tmp/gitproj' }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['commit', '--allow-empty', '-m', 'Initial commit'], + { cwd: '/tmp/gitproj' } + ) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/gitproj', + displayName: 'gitproj', + kind: 'git' + }) + ) + expect(result).toHaveProperty('repo.kind', 'git') + }) + + // ── rollback semantics ──────────────────────────────────────────── + + it('rolls back the directory it just created when git init fails', async () => { + gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up')) + + const result = await callCreate({ parentPath: '/tmp', name: 'broken', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/broken', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') }) + }) + + it('does NOT rm a pre-existing empty directory when git init fails', async () => { + // Pretend the directory already existed (and is empty) — user pre-created it. + accessMock.mockResolvedValueOnce(undefined) + readdirMock.mockResolvedValueOnce([]) + gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up')) + + const result = await callCreate({ parentPath: '/tmp', name: 'preexisting', kind: 'git' }) + + expect(rmMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') }) + }) + + it('surfaces an "initialize"-flavored error when git init itself fails', async () => { + // First git call (init) rejects — the commit step never runs. + gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('init broke')) + + const result = await callCreate({ parentPath: '/tmp', name: 'initfail', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/initfail', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + // Loose match — handler distinguishes init vs commit failures, and we want + // to tolerate small wording tweaks as long as it still mentions "initialize". + expect(result).toMatchObject({ error: expect.stringContaining('initialize') }) + }) + + it('rolls back directory when git commit fails (not just init)', async () => { + // init resolves, commit rejects — the failure must still trigger rollback + // and surface a commit-flavored error (distinct from the init-failure path). + gitExecFileAsyncMock + .mockReset() + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('commit broke')) + + const result = await callCreate({ parentPath: '/tmp', name: 'commitfail', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/commitfail', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ error: expect.stringContaining('commit') }) + }) + + it('strips only .git/ when commit fails in a pre-existing empty folder', async () => { + // User pre-created an empty folder; git init succeeded, commit failed. + // The folder itself must survive (user owns it) but the half-init'd + // .git/ should be removed so the folder looks untouched. + accessMock.mockResolvedValueOnce(undefined) + readdirMock.mockResolvedValueOnce([]) + gitExecFileAsyncMock + .mockReset() + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('commit broke')) + + const result = await callCreate({ parentPath: '/tmp', name: 'pre-existing', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/pre-existing/.git', { recursive: true, force: true }) + expect(rmMock).not.toHaveBeenCalledWith('/tmp/pre-existing', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ error: expect.stringContaining('commit') }) + }) + + // ── friendly messaging ──────────────────────────────────────────── + + it('surfaces a friendly message when git author identity is missing', async () => { + gitExecFileAsyncMock + .mockReset() + .mockResolvedValueOnce({ stdout: '', stderr: '' }) // git init + .mockRejectedValueOnce( + new Error('Please tell me who you are. Run git config --global user.email ...') + ) + + const result = await callCreate({ parentPath: '/tmp', name: 'authorless', kind: 'git' }) + + expect(rmMock).toHaveBeenCalledWith('/tmp/authorless', { recursive: true, force: true }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(result).toMatchObject({ + error: expect.stringContaining('Git author identity is not configured') + }) + }) + + // ── renderer notification ───────────────────────────────────────── + + it('notifies the renderer via repos:changed after a successful create', async () => { + await callCreate({ parentPath: '/tmp', name: 'notified', kind: 'folder' }) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed') + }) + + // ── authorized-roots cache refresh ──────────────────────────────── + + it('invalidates the authorized-roots cache after a successful folder create', async () => { + // Direct repo roots come from store state; invalidation clears stale linked + // roots without scanning every existing repo during creation. + await callCreate({ parentPath: '/tmp', name: 'rooted', kind: 'folder' }) + expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalledTimes(1) + }) + + it('does NOT rebuild the authorized-roots cache on a validation failure', async () => { + const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' }) + expect(result).toEqual({ error: 'Name cannot be empty' }) + expect(invalidateAuthorizedRootsCacheMock).not.toHaveBeenCalled() + }) + + it('does NOT rebuild the authorized-roots cache when dedup short-circuits', async () => { + const existing = { id: 'abc', path: '/tmp/dupe2', displayName: 'dupe2', kind: 'git' } + mockStore.getRepos.mockReturnValue([existing]) + + await callCreate({ parentPath: '/tmp', name: 'dupe2', kind: 'git' }) + + expect(invalidateAuthorizedRootsCacheMock).not.toHaveBeenCalled() + }) + + // ── dedup-by-path ───────────────────────────────────────────────── + + it('returns the existing repo when one already lives at the target path', async () => { + const existing = { id: 'abc', path: '/tmp/dupe', displayName: 'dupe', kind: 'git' } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await callCreate({ parentPath: '/tmp', name: 'dupe', kind: 'git' }) + + expect(result).toEqual({ repo: existing }) + // Short-circuit before any fs or git work. + expect(mkdirMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 6f6f288f59d..871a98948bb 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -54,7 +54,7 @@ vi.mock('../git/repo', async () => { }) vi.mock('./filesystem-auth', () => ({ - rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined) + invalidateAuthorizedRootsCache: vi.fn() })) vi.mock('../providers/ssh-git-dispatch', () => ({ diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index 71ab88b6a4d..cab50873817 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -44,7 +44,7 @@ vi.mock('../git/repo', () => ({ })) vi.mock('./filesystem-auth', () => ({ - rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined) + invalidateAuthorizedRootsCache: vi.fn() })) vi.mock('../providers/ssh-git-dispatch', () => ({ diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index d35624fac49..1e16236a81f 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -8,11 +8,11 @@ import type { Store } from '../persistence' import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { REPO_COLORS } from '../../shared/constants' -import { rebuildAuthorizedRootsCache } from './filesystem-auth' +import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' -import { rm } from 'fs/promises' -import { gitSpawn } from '../git/runner' -import { join, basename } from 'path' +import { access, mkdir, readdir, rm } from 'fs/promises' +import { gitExecFileAsync, gitSpawn } from '../git/runner' +import { basename, isAbsolute, join } from 'path' import { isGitRepo, getGitUsername, @@ -29,6 +29,30 @@ import { import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { getActiveMultiplexer } from './ssh' import { normalizeSparseDirectories } from './sparse-checkout-directories' +import { track } from '../telemetry/client' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import type { RepoMethod } from '../../shared/telemetry-events' + +// Why: `method` answers "which entry point did the user take?", not "what did +// they add?" — so the IPC the renderer invoked IS the method. We never send +// the path, URL, or display name. `repos:create` collapses into +// `folder_picker` because the user's entry was the folder picker, even +// though main also `git init`s. `drag_drop` is reserved for a future call +// site; no current renderer surface produces it. +function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void { + // Why: re-adding an existing repo (matched by path inside the handler) + // is not a new activation event. Suppressing the duplicate keeps the + // funnel honest and avoids inflating `repo_added` for users who + // re-pick the same folder. + if (alreadyExisted) { + return + } + // Why: cohort must read AFTER `store.addRepo()` lands so the just-added + // repo is counted — every call site below already emits post-addRepo, so + // `getCohortAtEmit()` here returns the user's Nth `repo_added` as `N`. + // See docs/onboarding-funnel-cohort-addendum.md §Read-vs-write ordering. + track('repo_added', { method, ...getCohortAtEmit() }) +} // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, @@ -42,6 +66,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:remove') + ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:update') ipcMain.removeHandler('repos:pickFolder') ipcMain.removeHandler('repos:pickDirectory') @@ -51,6 +76,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:getBaseRefDefault') ipcMain.removeHandler('repos:searchBaseRefs') ipcMain.removeHandler('repos:addRemote') + ipcMain.removeHandler('repos:create') ipcMain.removeHandler('sparsePresets:list') ipcMain.removeHandler('sparsePresets:save') ipcMain.removeHandler('sparsePresets:remove') @@ -73,6 +99,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // Check if already added const existing = store.getRepos().find((r) => r.path === args.path) if (existing) { + emitRepoAdded('folder_picker', true) return { repo: existing } } @@ -86,8 +113,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } store.addRepo(repo) - await rebuildAuthorizedRootsCache(store) + invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) + emitRepoAdded('folder_picker', false) return { repo } } ) @@ -134,6 +162,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v .getRepos() .find((r) => r.connectionId === args.connectionId && r.path === resolvedPath) if (existing) { + emitRepoAdded('folder_picker', true) return { repo: existing } } @@ -194,13 +223,220 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v mux.notify('session.registerRoot', { rootPath: resolvedPath }) } + emitRepoAdded('folder_picker', false) return { repo } } ) + // Creates a new repo or folder from scratch (orca#763). An empty initial + // commit is required for git repos so HEAD has a branch ref — Orca's + // worktree features all need one. + ipcMain.handle( + 'repos:create', + async ( + _event, + args: { parentPath: string; name: string; kind: 'git' | 'folder' } + ): Promise<{ repo: Repo } | { error: string }> => { + const name = args.name?.trim() ?? '' + const parentPath = args.parentPath?.trim() ?? '' + // Why: IPC input is untrusted — coerce to the narrow union so a bogus + // string (e.g. "x") can't skip git init yet persist as kind: "x" in the + // store. Mirrors the coercion in repos:add above. + const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' + + if (!name) { + return { error: 'Name cannot be empty' } + } + // Block slashes and ./.. so the name can't escape the chosen parent. + // The UI already disables submit in these cases; this guards direct IPC use. + if (/[\\/]/.test(name) || name === '.' || name === '..') { + return { error: 'Name cannot contain slashes or be "." / ".."' } + } + if (!parentPath) { + return { error: 'Parent directory is required' } + } + // Why: blocks CWD-relative paths from slipping through the IPC boundary; + // the UI uses pickDirectory which returns absolute paths, this guards + // direct IPC use (and keeps targetPath stable across process cwd changes). + if (!isAbsolute(parentPath)) { + return { error: 'Parent directory must be an absolute path' } + } + + const targetPath = join(parentPath, name) + + // Dedup by path (same as repos:add) so a double-click on Create doesn't + // produce two sidebar entries pointing at the same folder. This is the + // first of three dedup checks; see the pre-addRepo check below for why + // the race matters even after this one passes. + const existing = store.getRepos().find((r) => r.path === targetPath) + if (existing) { + emitRepoAdded('folder_picker', true) + return { repo: existing } + } + + // Empty pre-existing directories are allowed (e.g. one the user made in + // Finder first). Non-empty ones are rejected so we don't overwrite files. + let createdDir = false + let targetExists = false + try { + await access(targetPath) + targetExists = true + } catch (err) { + // Why: only ENOENT means "the path is free to use". Other codes + // (EACCES, ENOTDIR, EPERM, ELOOP, ...) mean something is in the way + // that mkdir can't fix — surface a precise error instead of falling + // through to mkdir and returning a misleading "Failed to create + // directory" message. + // + // Why the message fallback: fs.promises.access always attaches a + // NodeJS.ErrnoException code in production, but plain Error objects + // thrown in tests / non-Node contexts won't — treat a message that + // reads like ENOENT as one so we don't over-reject. + const code = + err && typeof err === 'object' && 'code' in err + ? (err as NodeJS.ErrnoException).code + : undefined + const looksLikeEnoent = + code === 'ENOENT' || + (code === undefined && err instanceof Error && /ENOENT/.test(err.message)) + if (!looksLikeEnoent) { + const message = err instanceof Error ? err.message : String(err) + return { error: `Cannot access target path: ${message}` } + } + } + + if (targetExists) { + try { + const entries = await readdir(targetPath) + if (entries.length > 0) { + return { + error: `"${name}" already exists at this location and is not empty.` + } + } + } catch (err) { + // Why: access succeeded but readdir failed — the path exists but we + // can't inspect it (e.g. it's a file, not a directory; or perms). + // mkdir would definitely fail here too, so return a distinct error. + const message = err instanceof Error ? err.message : String(err) + return { error: `Failed to read directory: ${message}` } + } + } else { + try { + await mkdir(targetPath, { recursive: false }) + createdDir = true + } catch (err) { + // Why: EEXIST here means another concurrent repos:create for the + // same path won the mkdir race. If they already added the repo to + // the store, return that entry instead of a confusing error. This + // is the second dedup check; see the pre-addRepo check below for + // the full race explanation. + const code = + err && typeof err === 'object' && 'code' in err + ? (err as NodeJS.ErrnoException).code + : undefined + const isEexist = code === 'EEXIST' || (err instanceof Error && /EEXIST/.test(err.message)) + if (isEexist) { + const raceWinner = store.getRepos().find((r) => r.path === targetPath) + if (raceWinner) { + return { repo: raceWinner } + } + } + const message = err instanceof Error ? err.message : String(err) + return { error: `Failed to create directory: ${message}` } + } + } + + if (repoKind === 'git') { + // Why: track which git step is running so the catch can attribute the + // failure correctly. The identity-hint regex is only meaningful during + // commit — git init itself never produces "Please tell me who you are". + let step: 'init' | 'commit' = 'init' + try { + await gitExecFileAsync(['init'], { cwd: targetPath }) + step = 'commit' + await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { + cwd: targetPath + }) + } catch (err) { + // Only remove the directory if we made it. A pre-existing folder the + // user picked must survive so they can retry after fixing git config. + // Why: if we didn't make the directory but `git init` created `.git/` + // inside it, strip just `.git/` so the user's folder looks the way + // they left it. Retrying works either way, but leaving a half-init'd + // repo behind is confusing if they choose to skip the retry. + if (createdDir) { + await rm(targetPath, { recursive: true, force: true }).catch(() => {}) + } else if (step === 'commit') { + await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {}) + } + const message = err instanceof Error ? err.message : String(err) + if ( + step === 'commit' && + /Please tell me who you are|user\.name|user\.email/i.test(message) + ) { + return { + error: + 'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.' + } + } + const stepLabel = + step === 'init' + ? 'Failed to initialize git repository' + : 'Failed to create initial commit' + return { error: `${stepLabel}: ${message}` } + } + } + + // Why: ipcMain.handle doesn't serialize concurrent calls; re-running the + // dedup lookup here closes the window between the first check and + // addRepo. A second repos:create for the same path that raced past the + // initial dedup now returns the entry the first call persisted. + const raceWinner = store.getRepos().find((r) => r.path === targetPath) + if (raceWinner) { + // Why: do NOT rm even if this invocation created the directory — the + // other invocation is using it. Leaking a freshly-made empty folder on + // a rare race is strictly safer than deleting a directory the winning + // call (and the user) now owns. + emitRepoAdded('folder_picker', true) + return { repo: raceWinner } + } + + const repo: Repo = { + id: randomUUID(), + path: targetPath, + displayName: name, + badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + addedAt: Date.now(), + kind: repoKind + } + + store.addRepo(repo) + invalidateAuthorizedRootsCache() + notifyReposChanged(mainWindow) + emitRepoAdded('folder_picker', false) + return { repo } + } + ) + + ipcMain.handle( + 'repos:reorder', + (_event, args: { orderedIds: string[] }): { status: 'applied' | 'rejected' } => { + // Why: validate at the IPC boundary — IPC input is untrusted and a + // permutation mismatch means the renderer's drag was stale relative to + // a concurrent add/remove. Reject so the renderer can resync. + const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : [] + const applied = store.reorderRepos(ids) + if (applied) { + notifyReposChanged(mainWindow) + return { status: 'applied' } + } + return { status: 'rejected' } + } + ) + ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { store.removeRepo(args.repoId) - await rebuildAuthorizedRootsCache(store) + invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) }) @@ -218,6 +454,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'hookSettings' | 'worktreeBaseRef' | 'kind' + | 'symlinkPaths' | 'issueSourcePreference' > > @@ -239,6 +476,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) { delete updates.issueSourcePreference } + // Why: `symlinkPaths` is consumed by `createWorktreeSymlinks` which + // calls `.trim()` on each entry. A renderer bug or preload-version skew + // that persists a non-`string[]` value (e.g. `[42, null]`, a bare + // string) would throw inside the worktree-create path with no UI + // signal. Strip invalid shapes at the boundary the same way + // `issueSourcePreference` is validated above. + if ('symlinkPaths' in updates && updates.symlinkPaths !== undefined) { + const v = updates.symlinkPaths as unknown + if (!Array.isArray(v) || !v.every((e) => typeof e === 'string')) { + delete updates.symlinkPaths + } + } const updated = store.updateRepo(args.repoId, updates) if (updated) { notifyReposChanged(mainWindow) @@ -347,6 +596,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (!repoName) { throw new Error('Could not determine repository name from URL') } + // Why: gitSpawn uses args.destination as cwd, so it must exist before + // spawn — fresh installs may have a defaulted parent dir that does not + // exist yet (e.g. ~/orca). recursive: true is a no-op when present. + await mkdir(args.destination, { recursive: true }) const clonePath = join(args.destination, repoName) // Why: use spawn instead of execFile so there is no maxBuffer limit. @@ -420,9 +673,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v const updated = store.updateRepo(existing.id, { kind: 'git' }) if (updated) { notifyReposChanged(mainWindow) + // Why: folder→git upgrade is a real new git repo provisioning event. + emitRepoAdded('clone_url', false) return updated } } + emitRepoAdded('clone_url', true) return existing } @@ -436,8 +692,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } store.addRepo(repo) - await rebuildAuthorizedRootsCache(store) + invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) + emitRepoAdded('clone_url', false) return repo } ) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 1460d6c9599..14a4de52ffb 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -20,4 +20,42 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle('runtime:getStatus', (): RuntimeStatus => { return runtime.getStatus() }) + + ipcMain.removeHandler('runtime:getTerminalFitOverrides') + ipcMain.handle( + 'runtime:getTerminalFitOverrides', + (): { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] => { + const overrides = runtime.getAllTerminalFitOverrides() + return Array.from(overrides.entries()).map(([ptyId, override]) => ({ + ptyId, + ...override + })) + } + ) + + // Why: the desktop "Restore" button sets the display mode to 'desktop' and + // applies it, which restores the PTY to its original dimensions and emits + // a 'resized' event to any active mobile subscriber. This uses the same + // code path as the mobile toggle button (terminal.setDisplayMode RPC). + ipcMain.removeHandler('runtime:restoreTerminalFit') + ipcMain.handle('runtime:restoreTerminalFit', async (_event, args: { ptyId: string }) => { + // Why: this IPC powers the desktop "Take back" button. Beyond restoring + // PTY dims (the original semantic), it now also reclaims the input + // floor for the desktop via the driver state machine. The lock banner + // unmounts and desktop input/resize are unblocked until the next + // mobile interaction takes the floor again. See + // docs/mobile-presence-lock.md. + // + // Why async: reclaimTerminalForDesktop awaits applyMobileDisplayMode's + // PTY-resize chain. Returning the unresolved Promise to ipcMain made + // Electron try to structured-clone a Promise — "An object could not + // be cloned" error — and the renderer's restoreTerminalFit() rejected + // with no useful info. + try { + const reclaimed = await runtime.reclaimTerminalForDesktop(args.ptyId) + return { restored: reclaimed } + } catch { + return { restored: false } + } + }) } diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 92aecef4868..2029227ef12 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -4,6 +4,13 @@ import type { GlobalSettings, PersistedState } from '../../shared/types' import { listSystemFontFamilies } from '../system-fonts' import { previewGhosttyImport } from '../ghostty/index' import { rebuildAppMenu } from '../menu/register-app-menu' +import { track } from '../telemetry/client' +import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events' + +// Why: the whitelist is the source-of-truth for which keys we emit on. Casting +// to a Set once at module load lets the IPC handler's per-key membership +// check stay O(1) without re-coercing the readonly tuple on every call. +const SETTINGS_CHANGED_WHITELIST_SET = new Set(SETTINGS_CHANGED_WHITELIST) // Why: fields that appear in the View > Appearance submenu need the menu // rebuilt after any update so the checkbox `checked` state stays in sync @@ -11,7 +18,7 @@ import { rebuildAppMenu } from '../menu/register-app-menu' // items when the backing state changes. const APPEARANCE_MENU_KEYS: readonly (keyof GlobalSettings)[] = [ 'showTasksButton', - 'showTitlebarAgentActivity' + 'showTitlebarAppName' ] export function registerSettingsHandlers(store: Store): void { @@ -23,10 +30,43 @@ export function registerSettingsHandlers(store: Store): void { if (args.theme) { nativeTheme.themeSource = args.theme } + // Why: capture the pre-update value so we only emit when the value + // actually changes. The settings UI sometimes re-saves the same value + // (e.g. blur after a no-op edit), and a `settings_changed` event for a + // no-op flip would inflate the experimental-feature-adoption signal. + const before = store.getSettings() const result = store.updateSettings(args) if (APPEARANCE_MENU_KEYS.some((key) => key in args)) { rebuildAppMenu() } + + // Why: telemetry-plan.md§Settings — fire `settings_changed` only for + // whitelisted keys, with `value_kind` distinguishing booleans from + // string-enum settings. We deliberately do NOT send the raw value for + // non-enum settings; the whitelist is currently scoped to experimental + // toggles, all of which are booleans, so `value_kind === 'bool'` is + // the path the v1 enum has a slot for. If a non-bool whitelisted + // setting is ever added, extend the discriminator here at the same + // time the schema's `value_kind` enum gains the new value. + for (const key of Object.keys(args)) { + if (!SETTINGS_CHANGED_WHITELIST_SET.has(key)) { + continue + } + const beforeValue = (before as Record)[key] + const afterValue = (result as Record)[key] + if (beforeValue === afterValue) { + continue + } + if (typeof afterValue !== 'boolean') { + // No non-bool whitelist entries today; skip rather than guess. + continue + } + track('settings_changed', { + setting_key: key as SettingsChangedKey, + value_kind: 'bool' + }) + } + return result }) diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts new file mode 100644 index 00000000000..cd7ce56d893 --- /dev/null +++ b/src/main/ipc/shell.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, showOpenDialogMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + showOpenDialogMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + }, + shell: { + showItemInFolder: vi.fn(), + openExternal: vi.fn(), + openPath: vi.fn() + }, + dialog: { + showOpenDialog: showOpenDialogMock + } +})) + +import { registerShellHandlers } from './shell' + +describe('registerShellHandlers', () => { + beforeEach(() => { + handleMock.mockReset() + showOpenDialogMock.mockReset() + }) + + function getHandler(channel: string): (event: unknown, args?: unknown) => Promise { + registerShellHandlers() + const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === channel) + if (!call) { + throw new Error(`${channel} handler not registered`) + } + return call[1] as (event: unknown, args?: unknown) => Promise + } + + it('picks audio files with a constrained native dialog filter', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ['/Users/kaylee/Downloads/Note_block_pling.ogg'] + }) + + const handler = getHandler('shell:pickAudio') + await expect(handler({})).resolves.toBe('/Users/kaylee/Downloads/Note_block_pling.ogg') + expect(showOpenDialogMock).toHaveBeenCalledWith({ + properties: ['openFile'], + filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }] + }) + }) + + it('returns null when audio picking is canceled', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: true, + filePaths: [] + }) + + const handler = getHandler('shell:pickAudio') + await expect(handler({})).resolves.toBeNull() + }) +}) diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 62fc383ca5c..51dff5ae5b6 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -123,6 +123,17 @@ export function registerShellHandlers(): void { return result.filePaths[0] }) + ipcMain.handle('shell:pickAudio', async (): Promise => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }] + }) + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] + }) + // Why: copying a picked image next to the markdown file lets us insert a // relative path (e.g. `![](image.png)`) instead of embedding base64, // keeping markdown files small and portable. diff --git a/src/main/ipc/sidekick.ts b/src/main/ipc/sidekick.ts deleted file mode 100644 index ee8e97b6852..00000000000 --- a/src/main/ipc/sidekick.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { app, BrowserWindow, dialog, ipcMain } from 'electron' -import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import { basename, extname, join, normalize, sep } from 'node:path' -import type { CustomSidekick } from '../../shared/types' - -// Why: image-only sidekick uploads. Static + animated variants render natively -// via , so no 3D engine is needed. Main owns the accepted-format table as -// the single source of truth for what the renderer will try to display. -const IMAGE_FORMATS: Record = { - '.png': 'image/png', - '.apng': 'image/apng', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml' -} - -function classifyFile(src: string): { mimeType: string; ext: string } | null { - const ext = extname(src).toLowerCase() - const mime = IMAGE_FORMATS[ext] - if (!mime) { - return null - } - return { mimeType: mime, ext } -} - -// Why: custom user-uploaded images live in a dedicated folder under userData -// so they persist across updates but are scoped to the Orca install. We never -// trust paths the renderer hands us — the renderer only ever knows the opaque -// CustomSidekick.id; main resolves it to an absolute path inside this folder. -function getSidekicksDir(): string { - return join(app.getPath('userData'), 'sidekicks', 'custom') -} - -const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL. - -function isSafeId(id: string): boolean { - // UUIDs only; blocks path traversal and unexpected characters. - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) -} - -function resolveSidekickFile(id: string, fileName: string): string | null { - if (!isSafeId(id)) { - return null - } - // Why: the renderer hands back the persisted fileName (which includes the - // original extension so Blob MIME detection works). We still normalize and - // prefix-check against the sidekicks dir to defend against any edge case that - // slipped the id regex. - const safeName = basename(fileName) - if (!safeName.startsWith(`${id}.`)) { - return null - } - const filePath = normalize(join(getSidekicksDir(), safeName)) - if (!filePath.startsWith(normalize(getSidekicksDir()) + sep)) { - return null - } - return filePath -} - -export function registerSidekickHandlers(): void { - ipcMain.handle('sidekick:import', async (event): Promise => { - // Why: parent the file picker to the sender window so the dialog opens as - // a sheet attached to the main window. Without a parent, on macOS the - // dialog can land behind the main window. - const senderWindow = - BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() - const options: Electron.OpenDialogOptions = { - title: 'Pick sidekick', - properties: ['openFile'], - // Why: single filter and no `apng` extension. macOS file dialogs map - // filter extensions to UTIs; `apng` has no registered UTI, so including - // it can drop sibling extensions (notably `webp`) from the allowed set. - // APNG files carry the `.png` extension and are detected from magic - // bytes by the browser. - filters: [ - { - name: 'Sidekick image', - extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] - } - ] - } - const result = senderWindow - ? await dialog.showOpenDialog(senderWindow, options) - : await dialog.showOpenDialog(options) - if (result.canceled || result.filePaths.length === 0) { - return null - } - const src = result.filePaths[0] - const classified = classifyFile(src) - if (!classified) { - throw new Error('Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG.') - } - let srcStat: Awaited> - try { - srcStat = await stat(src) - } catch { - throw new Error('Could not read the selected file.') - } - if (!srcStat.isFile()) { - throw new Error('Selected path is not a file') - } - if (srcStat.size > MAX_BYTES) { - throw new Error( - `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_BYTES / (1024 * 1024)} MB.` - ) - } - - const dir = getSidekicksDir() - await mkdir(dir, { recursive: true }) - const id = randomUUID() - // Why: preserve original extension in the on-disk name so sidekick:read can - // rebuild the right Blob MIME via resolveSidekickFile without a separate - // lookup. The extension is only ever written by main (never the renderer). - const fileName = `${id}${classified.ext}` - const dest = join(dir, fileName) - try { - await copyFile(src, dest) - } catch { - await rm(dest, { force: true }).catch(() => {}) - throw new Error('Could not save the sidekick.') - } - - const rawLabel = basename(src, extname(src)).trim() - const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Custom sidekick' - return { - id, - label, - fileName, - mimeType: classified.mimeType - } - }) - - ipcMain.handle( - 'sidekick:read', - async (_event, id: string, fileName: string): Promise => { - const filePath = resolveSidekickFile(id, fileName) - if (!filePath) { - return null - } - try { - const buf = await readFile(filePath) - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) - } catch (error) { - console.warn('[sidekick-overlay] sidekick:read failed', error) - return null - } - } - ) - - ipcMain.handle('sidekick:delete', async (_event, id: string, fileName: string): Promise => { - const filePath = resolveSidekickFile(id, fileName) - if (!filePath) { - return - } - try { - await rm(filePath, { force: true }) - } catch (error) { - console.warn('[sidekick-overlay] sidekick:delete failed', error) - } - }) -} diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index cbabe7db0f1..874e677dbd7 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -34,12 +34,16 @@ export function registerSshBrowseHandler( // filenames containing spaces or special characters. The -1 flag outputs // one entry per line. The -p flag appends / to directories. // We resolve ~ and get the absolute path via `cd && pwd`. + // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission + // denied after a readable `cd ... && pwd`) propagates as a non-zero exit + // code rather than being indistinguishable from an empty directory. const command = `cd ${shellEscape(args.dirPath)} && pwd && ls -1ap` const channel = await conn.exec(command) return new Promise((resolve, reject) => { let stdout = '' let stderr = '' + let exitCode: number | null = null channel.on('data', (data: Buffer) => { stdout += data.toString() @@ -47,7 +51,28 @@ export function registerSshBrowseHandler( channel.stderr.on('data', (data: Buffer) => { stderr += data.toString() }) + // `exit` fires before `close`; capture the code so we can distinguish + // a failed `ls` that still produced `pwd` output from an empty listing. + channel.on('exit', (code: number | null) => { + exitCode = code + }) channel.on('close', () => { + // A null exitCode means the server closed the channel without + // sending an exit-status message (or signalled termination). We + // can't assume success — falling back to "empty stdout = empty + // directory" is exactly the bug the exit-code branch was added to + // fix. Treat any non-zero OR null exit as a failure when stderr + // has content, and otherwise require stdout to contain at least + // the resolved `pwd` line before accepting the result. + if (exitCode !== 0) { + const msg = + stderr.trim() || + (exitCode === null + ? 'Remote listing failed (channel closed without exit status)' + : `Remote listing failed (exit ${exitCode})`) + reject(new Error(msg)) + return + } if (stderr.trim() && !stdout.trim()) { reject(new Error(stderr.trim())) return diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 91bba2f0487..fd820e241f3 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -41,6 +41,39 @@ const connectInFlight = new Map>() // avoids that visual glitch. const testingTargets = new Set() +// Why: when a relay channel keeps dying (e.g. a stale --connect bridge keeps +// being replaced, a remote-side bug closes the channel right after handshake, +// or a mismatched relay binary refuses every handshake), the unguarded +// _onRelayLost handler reconnects as fast as the network allows, hammering +// both the local main process and the remote sshd in a tight loop. Track +// per-target reconnect attempts and apply exponential backoff so the loop +// terminates with a recoverable error instead of running forever. Successful +// `ready` resets the attempt counter for the next genuine drop. +type RelayLostBackoffState = { + attempts: number + lastAttemptStartedAt: number + pendingTimer: ReturnType | null +} +const relayLostBackoff = new Map() +const RELAY_LOST_MAX_ATTEMPTS = 6 +const RELAY_LOST_BASE_DELAY_MS = 500 +const RELAY_LOST_MAX_DELAY_MS = 15_000 +// Why: if a fresh reconnect's mux dies within this window, the new session +// never stabilized (a flap, not a real recovery). Without this clamp the +// attempt counter would be reset prematurely by a mux that "reached ready" +// only on paper. 5 seconds covers normal post-deploy provider registration +// and PTY reattach without being so generous that a real long-lived session +// looks like a flap. +const RELAY_LOST_STABILIZED_MS = 5_000 + +function clearRelayLostBackoff(targetId: string): void { + const state = relayLostBackoff.get(targetId) + if (state?.pendingTimer) { + clearTimeout(state.pendingTimer) + } + relayLostBackoff.delete(targetId) +} + function broadcastPortForwards(getMainWindow: () => BrowserWindow | null, targetId: string): void { const win = getMainWindow() if (!win || win.isDestroyed()) { @@ -162,6 +195,7 @@ export function registerSshHandlers( 'ssh:connect', 'ssh:disconnect', 'ssh:getState', + 'ssh:needsPassphrasePrompt', 'ssh:testConnection', 'ssh:addPortForward', 'ssh:updatePortForward', @@ -290,6 +324,7 @@ export function registerSshHandlers( await portForwardManager!.removeAllForwards(targetId) existingSession.dispose() activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) } // Why: create the session early so onStateChange sees it in 'deploying' @@ -321,6 +356,7 @@ export function registerSshHandlers( // needs a passphrase. credentialRequestedForTarget.delete(targetId) activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) const win = getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('ssh:state-changed', { @@ -352,22 +388,112 @@ export function registerSshHandlers( // triggers session.reconnect() using the live SSH connection. // Set before establish() so the callback is in place if the relay // dies during the deploy/connect sequence. + // Why: a wire-handshake mismatch (typed RelayVersionMismatchError) means + // the local client and remote daemon are at different code versions — + // no amount of backoff will reconcile them. Skip the relay-lost loop + // entirely and surface a terminal "please reconnect manually" error. + session.setOnTerminalRelayError((tid, err) => { + clearRelayLostBackoff(tid) + console.warn( + `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` + ) + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('ssh:state-changed', { + targetId: tid, + state: { + targetId: tid, + status: 'error', + error: err.message, + reconnectAttempt: 0 + } + }) + } + }) + session.setOnRelayLost((tid) => { const s = activeSessions.get(tid) if (!s) { return } const c = connectionManager?.getConnection(tid) - const t = sshStore?.getTarget(tid) - if (c) { - void s.reconnect(c, t?.relayGracePeriodSeconds) + if (!c) { + return } + const t = sshStore?.getTarget(tid) + + // Why: bounded exponential backoff. Without this, a remote-side bug + // that closes every fresh --connect channel turns into an infinite + // tight loop spawning relay deploys until the user force-quits. + const state = relayLostBackoff.get(tid) ?? { + attempts: 0, + lastAttemptStartedAt: 0, + pendingTimer: null + } + if (state.pendingTimer) { + // A retry is already scheduled — coalesce this burst. + return + } + if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { + console.warn( + `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` + ) + relayLostBackoff.delete(tid) + // Why: surface the failure so the renderer can prompt the user. + // A still-live SSH connection with a dead relay is otherwise an + // invisible failure — typing in remote terminals just stops working. + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('ssh:state-changed', { + targetId: tid, + state: { + targetId: tid, + status: 'error', + error: 'Relay channel kept dropping. Please reconnect.', + reconnectAttempt: 0 + } + }) + } + return + } + const delay = Math.min( + RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, + RELAY_LOST_MAX_DELAY_MS + ) + state.attempts += 1 + state.pendingTimer = setTimeout(() => { + state.pendingTimer = null + state.lastAttemptStartedAt = Date.now() + relayLostBackoff.set(tid, state) + const liveConn = connectionManager?.getConnection(tid) + if (!liveConn || !activeSessions.has(tid)) { + return + } + void s.reconnect(liveConn, t?.relayGracePeriodSeconds) + }, delay) + relayLostBackoff.set(tid, state) + console.warn( + `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` + ) }) // Why: fires after both establish() and reconnect() reach 'ready'. // Re-creates persisted port forwards so they survive app restarts - // and network blips without manual re-configuration. + // and network blips without manual re-configuration. We also clear + // the relay-lost backoff state so a subsequent genuine drop starts + // from a fresh attempt counter — but only if the session had a chance + // to stabilize, otherwise rapid `ready → lost → ready → lost` flaps + // would silently keep retrying forever. session.setOnReady((tid) => { + const state = relayLostBackoff.get(tid) + if (state) { + const stabilized = + state.lastAttemptStartedAt === 0 || + Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS + if (stabilized) { + relayLostBackoff.delete(tid) + } + } void restorePortForwards(tid, getMainWindow) }) @@ -392,6 +518,7 @@ export function registerSshHandlers( } catch (err) { // Relay deployment failed — disconnect SSH activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) await connectionManager!.disconnect(targetId) throw err } @@ -416,6 +543,7 @@ export function registerSshHandlers( await portForwardManager!.removeAllForwards(args.targetId) session.dispose() activeSessions.delete(args.targetId) + clearRelayLostBackoff(args.targetId) } await connectionManager!.disconnect(args.targetId) }) @@ -424,6 +552,21 @@ export function registerSshHandlers( return connectionManager!.getState(args.targetId) }) + // Why: callers that want to auto-connect (Cmd+J jump, terminal reattach) need + // to know whether doing so will pop a passphrase/password dialog. Auto-firing + // the connect is fine when no prompt is needed, but surprising otherwise — + // the user expects to enter the credential before the app starts connecting. + // Returns true if the target's last successful connect required a credential + // AND the live SshConnection (if any) does not already have one cached. + ipcMain.handle('ssh:needsPassphrasePrompt', (_event, args: { targetId: string }) => { + const target = sshStore!.getTarget(args.targetId) + if (!target?.lastRequiredPassphrase) { + return false + } + const conn = connectionManager!.getConnection(args.targetId) + return !conn?.hasCachedCredential() + }) + ipcMain.handle('ssh:testConnection', async (_event, args: { targetId: string }) => { const target = sshStore!.getTarget(args.targetId) if (!target) { diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 3908206c12b..5622502e875 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -1,66 +1,250 @@ -// IPC boundary behavior. Strict type narrows must drop obviously-malformed -// calls before they reach the validator (the renderer is in the threat -// model). Also pins the consent-mutation rate limit: ≤5 -// `telemetry:setOptIn` calls per session. +/* eslint-disable max-lines -- Why: a single test file pins the IPC boundary behavior for all four telemetry handlers plus the cohort-injection invariants; splitting would fragment the threat-model coverage. */ +// IPC boundary behavior for the telemetry surface. Strict type narrows must +// drop obviously-malformed calls before they reach the validator (the +// renderer is in the threat model). Pins the consent-mutation rate limit: +// ≤5 consent-related IPC calls per session. Pins the main-side `via` +// derivation: both `OptInVia` branches are reachable from renderer input, +// and the one path that must NOT emit (`acknowledgeBanner`) has its own +// channel and handler rather than being a flag on `setOptIn`. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import type { Store } from '../persistence' const handlers = new Map unknown>() -const { handleMock, trackMock, setOptInMock, consumeConsentMutationTokenMock } = vi.hoisted(() => ({ +const { + handleMock, + trackMock, + setOptInMock, + persistBannerAcknowledgeMock, + consumeConsentMutationTokenMock, + getCohortAtEmitMock, + getOnboardingCohortAtEmitMock +} = vi.hoisted(() => ({ handleMock: vi.fn(), trackMock: vi.fn(), setOptInMock: vi.fn(), - consumeConsentMutationTokenMock: vi.fn() + persistBannerAcknowledgeMock: vi.fn(), + consumeConsentMutationTokenMock: vi.fn(), + getCohortAtEmitMock: vi.fn(), + getOnboardingCohortAtEmitMock: vi.fn() })) vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) vi.mock('../telemetry/client', () => ({ track: trackMock, - setOptIn: setOptInMock + setOptIn: setOptInMock, + persistBannerAcknowledgeWithoutEmitting: persistBannerAcknowledgeMock })) vi.mock('../telemetry/burst-cap', () => ({ consumeConsentMutationToken: consumeConsentMutationTokenMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) +vi.mock('../telemetry/onboarding-cohort-classifier', () => ({ + getOnboardingCohortAtEmit: getOnboardingCohortAtEmitMock +})) -import { registerTelemetryHandlers } from './telemetry' +import { _resetStoreForTests, registerTelemetryHandlers } from './telemetry' function captureHandlers(): void { handlers.clear() for (const call of handleMock.mock.calls) { - const [channel, handler] = call as [string, typeof handlers extends Map ? V : never] + const [channel, handler] = call as [ + string, + typeof handlers extends Map ? V : never + ] handlers.set(channel, handler) } } +// Build a fake Store with a settable `telemetry` block. Tests reassign +// `settings.telemetry` between handler invocations to seed the two +// derivation states. +type FakeStoreState = { settings: GlobalSettings } +function makeFakeStore(telemetry: GlobalSettings['telemetry']): { + store: Store + state: FakeStoreState +} { + const state: FakeStoreState = { settings: { telemetry } as unknown as GlobalSettings } + const store = { + getSettings: vi.fn(() => state.settings), + updateSettings: vi.fn((updates: Partial) => { + state.settings = { ...state.settings, ...updates } as GlobalSettings + return state.settings + }) + } as unknown as Store + return { store, state } +} + +function registerWith(telemetry: GlobalSettings['telemetry']): FakeStoreState { + const { store, state } = makeFakeStore(telemetry) + registerTelemetryHandlers(store) + captureHandlers() + return state +} + describe('telemetry IPC handlers', () => { beforeEach(() => { handleMock.mockReset() trackMock.mockReset() setOptInMock.mockReset() + persistBannerAcknowledgeMock.mockReset() consumeConsentMutationTokenMock.mockReset() consumeConsentMutationTokenMock.mockReturnValue(true) - registerTelemetryHandlers() - captureHandlers() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 0 }) + getOnboardingCohortAtEmitMock.mockReset() + getOnboardingCohortAtEmitMock.mockReturnValue({ cohort: undefined }) + _resetStoreForTests() }) afterEach(() => { vi.restoreAllMocks() }) - it('registers both channels', () => { + it('registers all four channels', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: true + }) expect(handlers.has('telemetry:track')).toBe(true) expect(handlers.has('telemetry:setOptIn')).toBe(true) + expect(handlers.has('telemetry:acknowledgeBanner')).toBe(true) + expect(handlers.has('telemetry:getConsentState')).toBe(true) }) // ── telemetry:track ────────────────────────────────────────────────── - it('forwards a well-typed track call to track()', () => { + it('forwards a well-typed track call to track() and injects cohort for COHORT_EXTENDED events', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) const handler = handlers.get('telemetry:track')! handler({}, 'app_opened', {}) expect(trackMock).toHaveBeenCalledTimes(1) - expect(trackMock).toHaveBeenCalledWith('app_opened', {}) + expect(trackMock).toHaveBeenCalledWith('app_opened', { nth_repo_added: 2 }) + }) + + // The IPC handler's selectivity is load-bearing: schemas are `.strict()`, + // so injecting `nth_repo_added` on a non-cohort event would silently + // drop the entire event at the validator. Events outside `COHORT_EXTENDED` + // must reach `track()` unmodified. + it('does NOT inject cohort on events outside COHORT_EXTENDED', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:track')! + handler({}, 'settings_changed', { setting_key: 'editorAutoSave', value_kind: 'bool' }) + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('settings_changed', { + setting_key: 'editorAutoSave', + value_kind: 'bool' + }) + expect(getCohortAtEmitMock).not.toHaveBeenCalled() + }) + + // The renderer-only Setup-step events fire from React `onClick` and + // depend on the IPC handler injecting cohort — call sites stay + // synchronous and pass only their own props. + it('injects cohort for add_repo_setup_step_action (renderer-only event)', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 1 }) + const handler = handlers.get('telemetry:track')! + handler({}, 'add_repo_setup_step_action', { action: 'skip' }) + expect(trackMock).toHaveBeenCalledWith('add_repo_setup_step_action', { + action: 'skip', + nth_repo_added: 1 + }) + }) + + // Fail-soft: a degraded classifier returns `{ nth_repo_added: undefined }`. + // The schemas declare the field optional, so the event still validates. + it('forwards undefined cohort when the classifier returns undefined', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: undefined }) + const handler = handlers.get('telemetry:track')! + handler({}, 'app_opened', {}) + expect(trackMock).toHaveBeenCalledWith('app_opened', { nth_repo_added: undefined }) + }) + + // Threat-model parity with the cohort override test: a compromised + // renderer must NOT be able to forge `nth_repo_added` either. The same + // spread-order invariant applies — `{ ...baseProps, ...getCohortAtEmit() }` + // — and the same future-refactor regression risk exists. Pinning both + // fields keeps the threat model symmetric. + it('main-derived nth_repo_added overrides renderer-supplied value', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + const handler = handlers.get('telemetry:track')! + handler({}, 'app_opened', { nth_repo_added: 99 }) + expect(trackMock).toHaveBeenCalledWith('app_opened', { nth_repo_added: 2 }) + }) + + // ── Onboarding cohort injection (mirrors the nth_repo_added pattern) ── + + it('injects onboarding cohort on events whose schema declares cohort', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + getOnboardingCohortAtEmitMock.mockReturnValue({ cohort: 'fresh_install' }) + const handler = handlers.get('telemetry:track')! + handler({}, 'onboarding_step_viewed', { step: 1 }) + expect(trackMock).toHaveBeenCalledWith('onboarding_step_viewed', { + step: 1, + cohort: 'fresh_install' + }) + }) + + it('does NOT inject onboarding cohort on non-onboarding events', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + const handler = handlers.get('telemetry:track')! + handler({}, 'settings_changed', { setting_key: 'editorAutoSave', value_kind: 'bool' }) + expect(getOnboardingCohortAtEmitMock).not.toHaveBeenCalled() + }) + + it('forwards undefined onboarding cohort fail-soft', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: true, optedIn: null }) + getOnboardingCohortAtEmitMock.mockReturnValue({ cohort: undefined }) + const handler = handlers.get('telemetry:track')! + handler({}, 'onboarding_started', {}) + expect(trackMock).toHaveBeenCalledWith('onboarding_started', { cohort: undefined }) + }) + + // Threat-model invariant: a compromised renderer must NOT be able to forge + // `cohort` by including it in the props payload. The IPC handler spreads + // the main-derived cohort AFTER the caller-supplied props, so the main + // value wins. This test pins that invariant — flipping the spread order + // would silently let a compromised renderer fake any cohort value. + it('main-derived cohort overrides renderer-supplied cohort', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + getOnboardingCohortAtEmitMock.mockReturnValue({ cohort: 'fresh_install' }) + const handler = handlers.get('telemetry:track')! + // Caller tries to forge cohort='upgrade_backfill'; main must overwrite. + handler({}, 'onboarding_started', { cohort: 'upgrade_backfill' }) + expect(trackMock).toHaveBeenCalledWith('onboarding_started', { + cohort: 'fresh_install' + }) + }) + + // Threat-model invariant under degraded classifier: a compromised + // renderer must NOT be able to forge `cohort` even when the classifier + // fails soft to `{ cohort: undefined }`. The IPC handler spreads the + // classifier output AFTER the caller-supplied props, so an explicit + // `undefined` from the classifier still overwrites a forged value. A + // future refactor that switches the spread to a conditional assign + // (`if (c.cohort !== undefined) baseProps.cohort = c.cohort`) would + // silently regress this — pinning it here. + it('main-derived undefined cohort overrides renderer-supplied cohort (degraded classifier)', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: true, optedIn: true }) + getOnboardingCohortAtEmitMock.mockReturnValue({ cohort: undefined }) + const handler = handlers.get('telemetry:track')! + // Compromised renderer attempts to forge cohort='upgrade_backfill'; + // main strips it via the explicit-undefined spread. + handler({}, 'onboarding_started', { cohort: 'upgrade_backfill' }) + expect(trackMock).toHaveBeenCalledWith('onboarding_started', { + cohort: undefined + }) }) it('drops track calls with a non-string name', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) const handler = handlers.get('telemetry:track')! handler({}, 42, {}) handler({}, null, {}) @@ -69,6 +253,7 @@ describe('telemetry IPC handlers', () => { }) it('drops track calls with non-object props', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) const handler = handlers.get('telemetry:track')! handler({}, 'app_opened', 'string-not-object') handler({}, 'app_opened', 42) @@ -76,17 +261,20 @@ describe('telemetry IPC handlers', () => { }) it('treats null/undefined props as an empty object', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 0 }) const handler = handlers.get('telemetry:track')! handler({}, 'app_opened', null) handler({}, 'app_opened', undefined) expect(trackMock).toHaveBeenCalledTimes(2) - expect(trackMock).toHaveBeenNthCalledWith(1, 'app_opened', {}) - expect(trackMock).toHaveBeenNthCalledWith(2, 'app_opened', {}) + expect(trackMock).toHaveBeenNthCalledWith(1, 'app_opened', { nth_repo_added: 0 }) + expect(trackMock).toHaveBeenNthCalledWith(2, 'app_opened', { nth_repo_added: 0 }) }) - // ── telemetry:setOptIn ─────────────────────────────────────────────── + // ── telemetry:setOptIn — input narrowing ───────────────────────────── it('drops setOptIn with non-boolean optedIn', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) const handler = handlers.get('telemetry:setOptIn')! handler({}, 'true') handler({}, 1) @@ -97,19 +285,176 @@ describe('telemetry IPC handlers', () => { expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() }) - it('forwards a boolean setOptIn to setOptIn(settings, optedIn) when the token is available', () => { - const handler = handlers.get('telemetry:setOptIn')! - consumeConsentMutationTokenMock.mockReturnValue(true) - handler({}, true) - expect(setOptInMock).toHaveBeenCalledWith('settings', true) - handler({}, false) - expect(setOptInMock).toHaveBeenCalledWith('settings', false) - }) - it('drops setOptIn past the consent-mutation rate limit', () => { + registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) const handler = handlers.get('telemetry:setOptIn')! consumeConsentMutationTokenMock.mockReturnValue(false) handler({}, true) expect(setOptInMock).not.toHaveBeenCalled() }) + + // ── telemetry:setOptIn — `via` derivation ──────────────────────────── + + it("derives via='first_launch_banner' for an existing user with optedIn=null clicking Turn off", () => { + // Existing-user notice is the only path where an existing user (cohort + // marker true) with optedIn=null flips to false. That is the contract + // the notice's "Turn off" button routes through. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: null + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, false) + expect(setOptInMock).toHaveBeenCalledWith('first_launch_banner', false) + }) + + it("derives via='settings' (not 'first_launch_banner') for a defensive opt-in call from the pre-notice state", () => { + // Defensive: the notice's opt-in path is the ✕ (silent acknowledge), + // which does NOT route through setOptIn. A compromised renderer + // could try to call telemetrySetOptIn(true) in the pre-notice state + // and synthesize a spurious telemetry_opted_in { via: + // 'first_launch_banner' }. The derivation must refuse that tag for + // the true-incoming case and fall through to 'settings'. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: null + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + }) + + it("derives via='settings' for a new user toggling off from Settings (no first-launch surface exists)", () => { + // New users (existedBeforeTelemetryRelease=false) are initialized with + // optedIn=true at migration and see no first-launch surface. Any + // opt-out from this cohort routes through Settings → Privacy and + // must tag as `via: 'settings'`. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: true + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, false) + expect(setOptInMock).toHaveBeenCalledWith('settings', false) + }) + + it("derives via='settings' for an opt-in toggle flip after a prior opt-out", () => { + // User flipped off in Settings, flipping back on in Settings. Neither + // cohort marker nor notice state triggers a first-launch tag. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: false + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + }) + + it("derives via='settings' for a new user flipping Settings off→on (not a first-launch interaction)", () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: false + }) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + }) + + it("derives via='settings' when the telemetry block is missing (defensive)", () => { + // Should never happen post-migration, but if it does the handler must + // fall through to 'settings' rather than throwing or mis-tagging. + registerWith(undefined) + const handler = handlers.get('telemetry:setOptIn')! + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + }) + + // ── telemetry:acknowledgeBanner — silent-persist path ──────────────── + + it('routes banner ✕ through persistBannerAcknowledgeWithoutEmitting without invoking setOptIn', () => { + // This is the whole point of the separate channel: the silent-persist + // path MUST NOT reach setOptIn, which would derive a `via` and fire + // `telemetry_opted_in`. The client primitive may unlock `app_opened`, + // but the acknowledge channel itself must not emit an opt-in event. + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: null + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).toHaveBeenCalledTimes(1) + expect(setOptInMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('acknowledgeBanner consumes a consent-mutation token and drops past the cap', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: null + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + consumeConsentMutationTokenMock.mockReturnValue(false) + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + }) + + // ── telemetry:acknowledgeBanner — state-precondition guard ─────────── + // These tests pin the guard, which rejects any cohort/optedIn + // combination other than (existed=true, optedIn=null). The guard is + // the defense against a compromised renderer silently flipping + // optedIn=true for a user who already resolved consent — a future + // refactor that weakens it must fail here. The guard also runs BEFORE + // consumeConsentMutationToken, so a rejected call must not burn a + // token either. + + it('acknowledgeBanner rejects an existing user who already opted in', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: true + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) + + it('acknowledgeBanner rejects an existing user who already opted out', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: true, + optedIn: false + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) + + it('acknowledgeBanner rejects the new-user cohort regardless of optedIn', () => { + registerWith({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: true + }) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) + + it('acknowledgeBanner rejects a missing telemetry block', () => { + registerWith(undefined) + const handler = handlers.get('telemetry:acknowledgeBanner')! + handler({}) + expect(persistBannerAcknowledgeMock).not.toHaveBeenCalled() + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 4d280b5520f..29d57536d6d 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -1,14 +1,17 @@ -// IPC surface for the telemetry transport. Two handlers, both renderer- -// facing: one pipe (`telemetry:track`) and one consent-mutation -// (`telemetry:setOptIn`). Every track call from the renderer lands here, -// and from here funnels into the same `track()` the main-originated events -// go through — the validator is the single enforcement point, not this -// file. +// IPC surface for the telemetry transport. Four handlers, all renderer- +// facing: one pipe (`telemetry:track`), one consent-mutation +// (`telemetry:setOptIn`), one silent-persist for the banner ✕ path +// (`telemetry:acknowledgeBanner`), and one read-only getter for effective +// consent state (`telemetry:getConsentState`, used by the Privacy pane to +// render env-var blocked-state helper text). Every track call from the +// renderer lands here and funnels into the same `track()` the main- +// originated events go through — the validator is the single enforcement +// point, not this file. // // Threat model: the renderer renders attacker-controllable content (agent // output, MCP responses, file contents, markdown, diff views). An // XSS-equivalent rendering bug in any of those surfaces gives an attacker -// the ability to invoke `window.api.telemetry*` at will. Both handlers +// the ability to invoke `window.api.telemetry*` at will. The handlers // below are designed to fail closed under that model: // // - Strict main-side type narrows. TypeScript types do not survive IPC @@ -18,53 +21,219 @@ // - Consent-mutation rate limit. A real user flips the Privacy pane // toggle a handful of times at most; beyond 5 per session it is either // a UI bug or a compromised renderer. Drop silently past the cap. +// Applies to `acknowledgeBanner` as well — that path also mutates +// persisted consent, so it lives under the same per-session ceiling. +// +// `via` derivation: the renderer does NOT pass `via` across the wire. That +// design was rejected specifically because a compromised renderer could +// misreport `via`, muddying the one signal we use to distinguish +// first-launch interactions from settings flips. Main derives `via` from +// fields main already owns (`existedBeforeTelemetryRelease`, current +// `optedIn`) before any state mutation. The two product paths map cleanly +// to the two schema values (`first_launch_banner` for the existing-user +// notice's "Turn off" button; `settings` for everything else — new users +// have no first-launch surface, so their opt-outs always come through +// Settings). import { ipcMain } from 'electron' import { consumeConsentMutationToken } from '../telemetry/burst-cap' -import { setOptIn, track } from '../telemetry/client' +import { persistBannerAcknowledgeWithoutEmitting, setOptIn, track } from '../telemetry/client' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { getOnboardingCohortAtEmit } from '../telemetry/onboarding-cohort-classifier' +import { resolveConsent, type ConsentState } from '../telemetry/consent' +import type { Store } from '../persistence' +import { isCohortExtendedEvent, isOnboardingEvent } from '../../shared/telemetry-events' import type { EventName, EventProps } from '../../shared/telemetry-events' +import type { OptInVia } from '../../shared/telemetry-events' -export function registerTelemetryHandlers(): void { - ipcMain.handle( - 'telemetry:track', - (_event, name: unknown, props: unknown): void => { - // Strict input typing: non-string names are dropped at the boundary - // before the validator even sees them. The validator would also drop - // (unknown event name), but the main-side narrow keeps the attack - // surface minimal — a flood of bogus payloads does not exercise the - // Zod parser for no reason. - if (typeof name !== 'string') { - return - } - // `props` may legitimately be omitted; treat `undefined`/`null` as an - // empty object before the validator. Anything else non-object (e.g. - // a string, a number) is a boundary violation. - if (props !== null && props !== undefined && typeof props !== 'object') { - return - } - // The casts to `EventName` / `EventProps` here are - // pass-through only — this file does NOT pretend the renderer's - // name/props are type-safe. The validator inside `track()` is the - // single enforcement point at runtime; these casts only feed the - // typed channel that the validator will re-check. - track( - name as EventName, - (props ?? {}) as EventProps - ) +// Module-level store reference, populated by `registerTelemetryHandlers`. +// The handlers need a synchronous read of `settings.telemetry` at call time +// to derive `via` before any mutation runs — threading the store through +// every handler closure is the least-surprising shape for that, and it +// mirrors how other core-handlers accept the store explicitly. +let storeRef: Store | null = null + +/** + * Derive the `via` discriminator for a `telemetry:setOptIn` call from + * main-owned state. Called BEFORE any state mutation so the cohort + opt-in + * snapshot reflects the pre-click world. + * + * Two cases (mirrors the product surfaces defined in telemetry-plan.md): + * - existing-user notice "Turn off" → `'first_launch_banner'` + * (existedBeforeTelemetryRelease=true, optedIn=null, incoming false) + * - any other flip → `'settings'` + * (Privacy pane, post-first-launch mutations, etc.) + * + * New users never reach a `'first_launch_banner'` tag — their cohort marker + * is false and they see no first-launch surface at all (see telemetry-plan.md + * §First-launch experience). Any consent flip they make routes through + * Settings → Privacy and tags as `'settings'`. + * + * Note: the notice's ✕ (silent acknowledge) path does NOT come through this + * function — it routes through `telemetry:acknowledgeBanner` and + * `persistBannerAcknowledgeWithoutEmitting`, which intentionally does not + * emit. Derivation here would tag it as `'first_launch_banner'` and emit + * `telemetry_opted_in`, which the ✕-as-silent-acknowledge semantics + * forbid. + */ +function deriveOptInVia(store: Store, incomingOptedIn: boolean): OptInVia { + const telemetry = store.getSettings().telemetry + const existedBefore = telemetry?.existedBeforeTelemetryRelease === true + const currentOptedIn = telemetry?.optedIn + + // Existing-user cohort, notice still pending. The only surface that calls + // `setOptIn` in this state is the FirstLaunchBanner's "Turn off" click; + // the ✕ path does not route here (it goes through + // `telemetry:acknowledgeBanner`). We additionally narrow on + // `incomingOptedIn === false` as a defensive guard: a compromised + // renderer could otherwise call `telemetrySetOptIn(true)` in this + // pre-notice state and synthesize a spurious + // `telemetry_opted_in { via: 'first_launch_banner' }`, which the + // ✕-as-silent-acknowledge contract forbids. Falling through to + // 'settings' for the true case keeps the forbidden tag unreachable + // from IPC. + if (existedBefore && currentOptedIn === null && incomingOptedIn === false) { + return 'first_launch_banner' + } + + return 'settings' +} + +export function registerTelemetryHandlers(store: Store): void { + storeRef = store + + ipcMain.handle('telemetry:track', (_event, name: unknown, props: unknown): void => { + // Strict input typing: non-string names are dropped at the boundary + // before the validator even sees them. The validator would also drop + // (unknown event name), but the main-side narrow keeps the attack + // surface minimal — a flood of bogus payloads does not exercise the + // Zod parser for no reason. + if (typeof name !== 'string') { + return } - ) + // `props` may legitimately be omitted; treat `undefined`/`null` as an + // empty object before the validator. Anything else non-object (e.g. + // a string, a number) is a boundary violation. + if (props !== null && props !== undefined && typeof props !== 'object') { + return + } + // Inject cohort here, at the IPC entry, only for events whose schemas + // declare `nth_repo_added` (see `COHORT_EXTENDED` in telemetry-events.ts). + // The selectivity is load-bearing: schemas are `.strict()`, so adding + // `nth_repo_added` to an event that does not declare it would fail Zod + // validation and silently drop the entire event. The renderer call sites + // stay synchronous (matching the existing fire-and-forget shape) and + // avoid an extra IPC round-trip to fetch cohort. + // + // Onboarding events get the same treatment for the `cohort` property, + // gated by `isOnboardingEvent` (events whose schema declares `cohort`). + // The two injection sets are disjoint by construction today — no schema + // declares both `nth_repo_added` and `cohort` — but combining them via + // spread keeps that an additive change rather than a structural one. + const eventName = name as EventName + const baseProps = (props ?? {}) as Record + const withRepoCohort = isCohortExtendedEvent(eventName) + ? { ...baseProps, ...getCohortAtEmit() } + : baseProps + const finalProps = isOnboardingEvent(eventName) + ? { ...withRepoCohort, ...getOnboardingCohortAtEmit() } + : withRepoCohort + // The casts to `EventName` / `EventProps` here are + // pass-through only — this file does NOT pretend the renderer's + // name/props are type-safe. The validator inside `track()` is the + // single enforcement point at runtime; these casts only feed the + // typed channel that the validator will re-check. + track(eventName, finalProps as EventProps) + }) - ipcMain.handle('telemetry:setOptIn', (_event, optedIn: unknown): void => { + ipcMain.handle('telemetry:setOptIn', (_event, optedIn: unknown): Promise | void => { // Strict input typing — renderer can pass anything over IPC. if (typeof optedIn !== 'boolean') { return } + // Check storeRef BEFORE consuming a consent-mutation token. If the store + // isn't ready (pre-registration race, test harness misuse, a future + // refactor), consuming a token here would burn budget for a no-op and + // eventually block legitimate mutations from the same session. + if (!storeRef) { + return + } // Consent-mutation bucket: ≤5 per session. See `burst-cap.ts`. Does not // apply to main-originated consent mutations that bypass IPC (none // today; this is future-proofing rather than a current code path). if (!consumeConsentMutationToken()) { return } - setOptIn('settings', optedIn) + // Read settings BEFORE any state mutation. The derivation must see the + // pre-mutation world so an existing user clicking "Turn off" on the + // notice still presents as (optedIn=null → false) at the moment `via` + // is computed, not (optedIn=false → false) after the write lands. + const via = deriveOptInVia(storeRef, optedIn) + return setOptIn(via, optedIn) + }) + + // Read-only view of the effective consent state. The Privacy pane needs + // this to render the correct helper text when an env var + // (DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / CI) blocks transmission — + // those variables are main-side process state and the renderer has no + // way to read them directly. No rate limit: this is a synchronous getter + // with no mutation, bounded in work by one `resolveConsent` call. + ipcMain.handle('telemetry:getConsentState', (): ConsentState => { + if (!storeRef) { + // Fail closed — a missing store means we cannot honor the user's + // stored preference, so surface pending_banner rather than a + // misleading 'enabled'. The renderer treats pending_banner like a + // disabled state in the UI. + return { effective: 'pending_banner' } + } + return resolveConsent(storeRef.getSettings()) + }) + + ipcMain.handle('telemetry:acknowledgeBanner', (_event): Promise | void => { + // Banner ✕ — persist `optedIn = true` without emitting a telemetry opt-in + // event. The acknowledge still unlocks `app_opened`, but this outcome + // cannot route through `telemetry:setOptIn` because the derivation above + // would tag it `first_launch_banner` and fire `telemetry_opted_in`. + // + // Check storeRef BEFORE consuming a consent-mutation token, mirroring + // the setOptIn handler's guard above — see that comment for why + // burning a token on a no-op blocks legitimate mutations later in + // the same session. + if (!storeRef) { + return + } + // State-precondition guard: this channel is ONLY valid when the notice + // is pending resolution — i.e. existing-user cohort + // (existedBeforeTelemetryRelease=true) with optedIn still null. Any + // other state is either a UI bug (the notice should not be reachable + // post-resolution) or a compromised renderer trying to silently flip + // optedIn=true after the user already opted out, which would bypass + // the audit signal entirely. `deriveOptInVia` already applies the + // symmetric guard on `telemetry:setOptIn` (it refuses to tag a true + // flip as `first_launch_banner`); this narrows the silent-persist + // attack surface on the acknowledge channel to exactly the state the + // notice contract covers. Must run BEFORE the token consume — same + // "don't burn a token on a no-op" reasoning as the !storeRef guard + // above. + const telemetry = storeRef.getSettings().telemetry + if (telemetry?.existedBeforeTelemetryRelease !== true || telemetry?.optedIn !== null) { + return + } + // This path still goes through `consumeConsentMutationToken` — a + // compromised renderer could otherwise burn through the + // acknowledgeBanner channel to force unbounded settings-file writes, + // which is a CPU/disk amplification vector even without any event + // emission. + if (!consumeConsentMutationToken()) { + return + } + return persistBannerAcknowledgeWithoutEmitting() }) } + +// Test-only reset for the module-level store reference. Tests can +// re-register handlers against a fresh mock store without leaking state +// between describes. +export function _resetStoreForTests(): void { + storeRef = null +} diff --git a/src/main/ipc/workspace-create-error-classifier.test.ts b/src/main/ipc/workspace-create-error-classifier.test.ts new file mode 100644 index 00000000000..b4cc075d103 --- /dev/null +++ b/src/main/ipc/workspace-create-error-classifier.test.ts @@ -0,0 +1,76 @@ +// Spot-check the classifier against the load-bearing throws in +// worktree-remote.ts. Not exhaustive — substring widening still requires +// explicit review per the schema-evolution doctrine in +// telemetry-events.ts. Add fixtures here when adding new buckets or when +// a renamed throw site needs regression coverage. + +import { describe, expect, it } from 'vitest' +import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' + +describe('classifyWorkspaceCreateError', () => { + it('buckets the missing-base-ref throw as base_ref_missing', () => { + const err = new Error( + 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' + ) + expect(classifyWorkspaceCreateError(err)).toBe('base_ref_missing') + }) + + it('buckets a branch-already-exists throw as path_collision', () => { + const err = new Error('Branch "feature/foo" already exists. Pick a different worktree name.') + expect(classifyWorkspaceCreateError(err)).toBe('path_collision') + }) + + it('buckets the suffix-exhaustion throw as path_collision', () => { + const err = new Error( + 'Could not find an available worktree name for "feature". Pick a different worktree name.' + ) + expect(classifyWorkspaceCreateError(err)).toBe('path_collision') + }) + + it('buckets a branch-already-exists-locally throw as path_collision', () => { + const err = new Error( + 'Branch "feature/foo" already exists locally. Pick a different worktree name.' + ) + expect(classifyWorkspaceCreateError(err)).toBe('path_collision') + }) + + it('buckets an existing-PR collision throw as path_collision', () => { + const err = new Error( + 'Branch "feature/foo" already has PR #42. Pick a different worktree name.' + ) + expect(classifyWorkspaceCreateError(err)).toBe('path_collision') + }) + + it('buckets the post-create listing-miss throw as git_failed', () => { + const err = new Error('Worktree created but not found in listing') + expect(classifyWorkspaceCreateError(err)).toBe('git_failed') + }) + + it('buckets EACCES errors as permission_denied', () => { + const err = Object.assign(new Error("EACCES: permission denied, mkdir '/tmp/x'"), { + code: 'EACCES' + }) + expect(classifyWorkspaceCreateError(err)).toBe('permission_denied') + }) + + it('buckets generic git errors as git_failed', () => { + const err = new Error('fatal: not a git repository') + expect(classifyWorkspaceCreateError(err)).toBe('git_failed') + }) + + it('falls through to unknown for unrecognised errors', () => { + const err = new Error('something completely unexpected') + expect(classifyWorkspaceCreateError(err)).toBe('unknown') + }) + + it('falls through to unknown for SSH-precondition errors', () => { + const err = new Error('SSH connection is not available. Please reconnect and try again.') + expect(classifyWorkspaceCreateError(err)).toBe('unknown') + }) + + it('handles non-Error values without throwing', () => { + expect(classifyWorkspaceCreateError('a bare string')).toBe('unknown') + expect(classifyWorkspaceCreateError(undefined)).toBe('unknown') + expect(classifyWorkspaceCreateError(null)).toBe('unknown') + }) +}) diff --git a/src/main/ipc/workspace-create-error-classifier.ts b/src/main/ipc/workspace-create-error-classifier.ts new file mode 100644 index 00000000000..5a24493fd62 --- /dev/null +++ b/src/main/ipc/workspace-create-error-classifier.ts @@ -0,0 +1,53 @@ +// Bucket errors thrown by `createLocalWorktree` / `createRemoteWorktree` into +// the `workspace_create_failed` event's `error_class` enum. +// +// Why: the throw sites are bare `throw new Error('...')` calls in +// worktree-remote.ts, some of which interpolate user-controlled content +// (branch names, paths). The classifier reads `error.message` to bucket, but +// the matched strings never cross the wire — only the enum value does. The +// schema discipline at telemetry-events.ts §"Properties to keep off these +// events" is what makes that safe. +// +// Substring set is intentionally narrow: per the schema-evolution doctrine, +// widening this match set later requires explicit review, not silent regex +// expansion. Anything we cannot confidently bucket falls through to `unknown`. + +import type { WorkspaceCreateErrorClass } from '../../shared/telemetry-events' + +export function classifyWorkspaceCreateError(error: unknown): WorkspaceCreateErrorClass { + // Why: throw sites mix capitalization ('Worktree created...' vs lowercased + // git messages); normalize once so all anchors below can be lowercase literals. + const text = (error instanceof Error ? error.message : '').toLowerCase() + + if (text.includes('could not resolve a default base ref')) { + return 'base_ref_missing' + } + if ( + text.includes('already exists locally') || + text.includes('already exists on a remote') || + text.includes('already exists. pick') || + text.includes('already has pr') || + text.includes('could not find an available worktree name') + ) { + return 'path_collision' + } + if (text.includes('eacces') || text.includes('eperm') || text.includes('permission denied')) { + return 'permission_denied' + } + // Why: anchors are intentionally specific to true git failures from + // worktree-remote.ts. Bare 'git ' / 'worktree' would mis-bucket SSH-relay + // and sparse-checkout validation errors (which the design doc routes to + // 'unknown'); 'created but not found in listing' covers the formerly + // miscased 'Worktree created but not found in listing' throw. + // SSH-precondition failures (e.g. 'no git provider', 'SSH connection is + // not available') deliberately fall through to 'unknown' — they're not + // git failures and bucketing them here would mask connectivity issues. + if ( + text.includes('fatal:') || + text.includes('git worktree') || + text.includes('created but not found in listing') + ) { + return 'git_failed' + } + return 'unknown' +} diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 4b6b8e390c3..0715e891675 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -2,6 +2,7 @@ import { join, resolve } from 'path' import { describe, expect, it } from 'vitest' import { sanitizeWorktreeName, + sanitizeWorktreeDisplayName, ensurePathWithinWorkspace, computeBranchName, computeWorktreePath, @@ -51,6 +52,20 @@ describe('sanitizeWorktreeName', () => { expect(sanitizeWorktreeName('a..b...c')).toBe('a.b.c') }) + it('preserves non-ASCII letters and numbers', () => { + // Why: users name workspaces in their own language (CJK, accented Latin, + // Cyrillic, etc.). Stripping these to ASCII left the name empty and threw + // "Invalid worktree name" on every non-Latin keyboard input. + expect(sanitizeWorktreeName('中文')).toBe('中文') + expect(sanitizeWorktreeName('日本語 テスト')).toBe('日本語-テスト') + expect(sanitizeWorktreeName('café-déjà')).toBe('café-déjà') + expect(sanitizeWorktreeName('Привет мир')).toBe('Привет-мир') + }) + + it('still strips git-unsafe punctuation around Unicode names', () => { + expect(sanitizeWorktreeName('feat: 中文 (v2)')).toBe('feat-中文-v2') + }) + it('throws for empty name', () => { expect(() => sanitizeWorktreeName('')).toThrow('Invalid worktree name') }) @@ -60,6 +75,27 @@ describe('sanitizeWorktreeName', () => { }) }) +describe('sanitizeWorktreeDisplayName', () => { + it('keeps readable punctuation while collapsing unsafe controls and whitespace', () => { + expect(sanitizeWorktreeDisplayName(' Fix: login / callback\n\tregression\u0000 ')).toBe( + 'Fix: login / callback regression' + ) + }) + + it('strips bidi override controls from external artifact titles', () => { + expect(sanitizeWorktreeDisplayName('Review \u202eexe.txt')).toBe('Review exe.txt') + }) + + it('truncates very long titles', () => { + const title = 'a'.repeat(200) + expect(sanitizeWorktreeDisplayName(title)).toHaveLength(120) + }) + + it('returns undefined when nothing displayable remains', () => { + expect(sanitizeWorktreeDisplayName('\u0000\n\t')).toBeUndefined() + }) +}) + describe('ensurePathWithinWorkspace', () => { it('returns resolved path when within workspace', () => { const result = ensurePathWithinWorkspace('/workspace/feature', '/workspace') diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index f4fc0e292b8..c2568e12583 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -7,11 +7,13 @@ import { getWslHome, parseWslPath } from '../wsl' * Strips unsafe characters and collapses runs of special chars to a single hyphen. */ export function sanitizeWorktreeName(input: string): string { + // Why: keep Unicode letters/numbers (CJK, accented Latin, etc.) so users can + // name workspaces in their own language. Git ref-format permits non-ASCII + // bytes, and modern filesystems handle UTF-8 paths. Only strip characters + // git or the filesystem actually rejects. const sanitized = input .trim() - .replace(/[\\/]+/g, '-') - .replace(/\s+/g, '-') - .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/[^\p{L}\p{N}._-]+/gu, '-') .replace(/-+/g, '-') // Why: git check-ref-format rejects any ref containing `..`, so a prompt // like "../../foo" that survives slugification as `..-..-foo` would @@ -28,6 +30,23 @@ export function sanitizeWorktreeName(input: string): string { return sanitized } +export function sanitizeWorktreeDisplayName(input: string): string | undefined { + const withoutControls = Array.from(input, (char) => { + const code = char.charCodeAt(0) + return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : char + }).join('') + const sanitized = withoutControls + // Why: titles come from external systems. Strip bidi override controls so a + // malicious title cannot visually reorder adjacent sidebar text. + .replace(/[\u202a-\u202e\u2066-\u2069]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + .trim() + + return sanitized || undefined +} + /** * Ensure a target path is within the workspace directory (prevent path traversal). */ @@ -172,6 +191,7 @@ export function mergeWorktree( isPinned: meta?.isPinned ?? false, sortOrder: meta?.sortOrder ?? 0, lastActivityAt: meta?.lastActivityAt ?? 0, + ...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), ...(git.isSparse === true ? { sparseDirectories: meta?.sparseDirectories, @@ -179,6 +199,7 @@ export function mergeWorktree( sparsePresetId: meta?.sparsePresetId } : {}), + ...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}), // Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in // shared/types) and forwarded verbatim so the renderer store mirrors // on-disk state. `undefined` here means the worktree has no comments yet. diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 79e97e339b5..f284678d1e8 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -2,9 +2,9 @@ // Why: extracted from worktrees.ts to keep the main IPC module under the // max-lines threshold. Worktree creation helpers (local and remote) live // here so the IPC dispatch file stays focused on handler wiring. The -// recently added sparse-checkout flow plus the worktree-bound setup-script -// trust gate pushed this file marginally over the per-file limit; matches -// the eslint-disable pattern other files in src/renderer use when a +// sparse-checkout flow plus the post-create setup-runner wiring pushed +// this file marginally over the per-file limit; matches the +// eslint-disable pattern other files in src/renderer use when a // cohesive flow would split awkwardly. import type { BrowserWindow } from 'electron' @@ -21,18 +21,16 @@ import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' import { gitExecFileAsync } from '../git/runner' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' import { isWslPath, parseWslPath, getWslHome } from '../wsl' -import { - createSetupRunnerScript, - getEffectiveHooks, - setupScriptsMatch, - shouldRunSetupForCreate -} from '../hooks' +import { createSetupRunnerScript, getEffectiveHooks, shouldRunSetupForCreate } from '../hooks' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { getActiveMultiplexer } from './ssh' import type { SshGitProvider } from '../providers/ssh-git-provider' import { sanitizeWorktreeName, + sanitizeWorktreeDisplayName, computeBranchName, computeWorktreePath, ensurePathWithinWorkspace, @@ -41,14 +39,36 @@ import { areWorktreePathsEqual } from './worktree-logic' import { invalidateAuthorizedRootsCache } from './filesystem-auth' +import { createWorktreeSymlinks } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' +async function readCommitSha(repoPath: string, ref: string): Promise { + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', `${ref}^{commit}`], { + cwd: repoPath + }) + return stdout.trim() +} + export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('worktrees:changed', { repoId }) } } +// Why (§3.3): two-phase spinner. Main process fires `'fetching'` immediately +// after kicking off `git fetch` and `'creating'` after that fetch resolves +// (or is determined to be cache-fresh). Renderer swaps its spinner label in +// response; fallback is the static "Creating worktree..." label if no event +// arrives (e.g. renderer races destruction of the window). +export function emitCreateWorktreeProgress( + mainWindow: BrowserWindow, + phase: 'fetching' | 'creating' +): void { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('createWorktree:progress', { phase }) + } +} + export async function createRemoteWorktree( args: CreateWorktreeArgs, repo: Repo, @@ -67,6 +87,9 @@ export async function createRemoteWorktree( const settings = store.getSettings() const requestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) + const requestedDisplayName = args.displayName + ? sanitizeWorktreeDisplayName(args.displayName) + : undefined // Get git username from remote let username = '' @@ -126,25 +149,17 @@ export async function createRemoteWorktree( /* best-effort */ } - // Why: the relay's git.addWorktree validates targetDir against registered - // roots. The worktree sibling path (repo/../name) is outside the repo root - // and must be registered first. Using request (not notify) makes the - // ordering guarantee explicit rather than relying on FIFO frame processing, - // and closes failure windows during relay reconnect or fresh-host scenarios - // where roots may not yet be registered at all. See issue #911. const mux = getActiveMultiplexer(repo.connectionId!) if (!mux) { throw new Error('SSH connection is not available. Please reconnect and try again.') } - // Why: git.addWorktree validates both repoPath and targetDir against - // registered roots. In a fresh-host or reconnect scenario, registerRelayRoots - // may not have finished yet, so neither path may be registered. Register both - // synchronously here to close that window. - // - // Why (fallback): when Orca reconnects via --connect to a relay still in its - // grace period, the old relay binary may not have the request handler yet. - // Fall back to notify so worktree creation still works against pre-upgrade - // relays. + // Why: kept for back-compat with old relay binaries during the upgrade + // window — those still gate git.addWorktree on registered roots, so we + // must prime them synchronously to close fresh-host / reconnect windows. + // New relays no-op these calls. Notify-fallback handles older relays that + // pre-date the request-form session.registerRoot handler. Tracked for + // removal once the relay-version floor moves past the cutover (see + // docs/relay-fs-allowlist-removal.md). try { await Promise.all([ mux.request('session.registerRoot', { rootPath: repo.path }), @@ -162,8 +177,7 @@ export async function createRemoteWorktree( // Create worktree via relay try { await provider.addWorktree(repo.path, branchName, remotePath, { - base: baseBranch, - track: baseBranch.includes('/') + base: baseBranch }) } catch (err) { if ( @@ -171,13 +185,14 @@ export async function createRemoteWorktree( (err.message.includes('No workspace roots registered yet') || err.message.includes('Path outside authorized workspace')) ) { - // Why: validatePath throws two distinct errors — "No workspace roots - // registered yet" (relay has no roots at all, e.g., reconnect before - // registerRelayRoots completes) and "Path outside authorized workspace" - // (roots exist but the sibling worktree path isn't among them). Both are - // implementation details that mean nothing to the user. + // Why: only an OLD relay binary (pre-allowlist-removal) can produce + // these errors. New relays no-op session.registerRoot. Translate the + // raw error into an actionable upgrade-window message while still + // preserving the original string for bug reports. Tracked for removal + // once the relay-version floor moves past the cutover (see + // docs/relay-fs-allowlist-removal.md). throw new Error( - 'The SSH relay has not registered the worktree path yet. Please wait a moment and try again, or disconnect and reconnect the SSH session.' + `Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})` ) } throw err @@ -193,15 +208,31 @@ export async function createRemoteWorktree( } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const metaUpdates: Partial = { - lastActivityAt: Date.now(), - ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) - ? { displayName: requestedName } - : {}) + lastActivityAt: now, + // Why: grants the new worktree a short grace window at the top of the + // Recent sort. During worktree creation (git fetch + add can take several + // seconds) other worktrees get ambient PTY bumps that would otherwise + // leave the newly-created one below them; the Recent comparator uses + // max(lastActivityAt, createdAt + GRACE_MS) to keep it on top until the + // window elapses. See smart-sort.ts `CREATE_GRACE_MS`. + createdAt: now, + ...(requestedDisplayName + ? { displayName: requestedDisplayName } + : shouldSetDisplayName(requestedName, branchName, sanitizedName) + ? { displayName: requestedName } + : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) const worktree = mergeWorktree(repo.id, created, meta) + // Why: `experimentalWorktreeSymlinks` is intentionally not wired up for + // remote (SSH) worktrees. Creating symlinks on the remote host would + // require a new relay method and authorization surface; the feature is + // local-only until that protocol work is in scope. Remote repos with + // `symlinkPaths` configured have them silently ignored here. + notifyWorktreesChanged(mainWindow, repo.id) return { worktree } } @@ -210,13 +241,80 @@ export async function createLocalWorktree( args: CreateWorktreeArgs, repo: Repo, store: Store, - mainWindow: BrowserWindow + mainWindow: BrowserWindow, + runtime?: OrcaRuntimeService ): Promise { const settings = store.getSettings() const username = getGitUsername(repo.path) const requestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) + const requestedDisplayName = args.displayName + ? sanitizeWorktreeDisplayName(args.displayName) + : undefined + + // Why (§3.3): determine the base branch (and therefore the remote we need to + // fetch) FIRST, so the fetch can overlap all pre-create work below. Neither + // of these calls depends on the suffix loop / PR probe / branch-conflict + // resolution, so they are safe to hoist. + const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + if (!baseBranch) { + // Why: getDefaultBaseRef may return null when none of origin/HEAD, + // origin/main, origin/master, local main, or local master exist. Don't + // fall back to a hardcoded 'origin/main' — passing a non-existent ref to + // `git worktree add` produces an opaque error. Fail here with a clear + // message so the UI can prompt the user to pick a base branch explicitly. + throw new Error( + 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' + ) + } + + let optimisticBase: RemoteTrackingBase | null = null + let optimisticFetchPromise: Promise | null = null + let initialBaseStatus: CreateWorktreeResult['initialBaseStatus'] + let legacyFetchPromise: Promise | null = null + + if (runtime) { + optimisticBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch) + if (optimisticBase) { + const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, optimisticBase) + if (hasLocalBaseRef) { + const isFresh = await runtime.isRemoteFetchFresh(repo.path, optimisticBase.remote) + if (!isFresh) { + optimisticFetchPromise = runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote) + } + } else { + emitCreateWorktreeProgress(mainWindow, 'fetching') + const result = await runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote) + if (!(await runtime.hasRemoteTrackingRef(repo.path, optimisticBase))) { + if (!result.ok) { + throw new Error( + `Could not fetch base ref "${baseBranch}" from "${optimisticBase.remote}". Check your network and try again.` + ) + } + throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + } + } + } else { + // Why: when the base branch does not match a configured remote prefix + // (e.g. plain `main`, `master`, or any local branch), the legacy path + // still ran a best-effort `git fetch origin` so a local base could be + // built against fresher tracking refs. Preserve that behavior here so + // local-only bases don't silently skip the pre-create fetch. + const fallbackRemote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + legacyFetchPromise = runtime + .fetchRemoteWithCache(repo.path, fallbackRemote) + .then(() => undefined) + .catch(() => undefined) + emitCreateWorktreeProgress(mainWindow, 'fetching') + } + } else { + const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + legacyFetchPromise = gitExecFileAsync(['fetch', remote], { cwd: repo.path }) + .then(() => undefined) + .catch(() => undefined) + emitCreateWorktreeProgress(mainWindow, 'fetching') + } // Why: WSL worktrees live under ~/orca/workspaces inside the WSL // filesystem. Validate against that root, not the Windows workspace dir. // If WSL home lookup fails, keep using the configured workspace root so @@ -303,26 +401,12 @@ export async function createLocalWorktree( ) } - // Determine base branch. - // - // Why: getDefaultBaseRef may return null when none of origin/HEAD, - // origin/main, origin/master, local main, or local master exist. In that - // case we must not fall back to a hardcoded 'origin/main' — passing a - // non-existent ref to `git worktree add` produces an opaque error. Fail - // here with a clear message so the UI can prompt the user to pick a base - // branch explicitly. - const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) - if (!baseBranch) { - throw new Error( - 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' - ) - } - const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup // Why: `ask` is a pre-create choice gate, not a post-create side effect. // Resolve it before mutating git state so missing UI input cannot strand // a real worktree on disk while the renderer reports "create failed". The - // actual run/skip decision is recomputed after the worktree exists, gated - // on the worktree's own setup script matching the primary's preview. + // actual run/skip decision is recomputed after the worktree exists against + // the worktree-bound setup script. + const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup if (primarySetupScript) { shouldRunSetupForCreate(repo, args.setupDecision) } @@ -353,16 +437,10 @@ export async function createLocalWorktree( } } - // Why: `git fetch` previously blocked worktree creation for 1–5s on every - // click, even though the fetch result isn't actually required — the - // subsequent `git worktree add` uses whatever local ref `baseBranch` points - // at. Kicking fetch off in parallel lets the worktree be created off the - // last-known tip while the fetch completes in the background; the next - // user action (pull, diff, PR create) will see the refreshed remote state. - const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' - void gitExecFileAsync(['fetch', remote], { cwd: repo.path }).catch(() => { - // Fetch is best-effort — don't block worktree creation if offline - }) + if (legacyFetchPromise) { + await legacyFetchPromise + } + emitCreateWorktreeProgress(mainWindow, 'creating') await (sparseDirectories.length > 0 ? addSparseWorktree( @@ -389,14 +467,21 @@ export async function createLocalWorktree( } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const metaUpdates: Partial = { // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. - lastActivityAt: Date.now(), - ...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) - ? { displayName: effectiveRequestedName } - : {}), + lastActivityAt: now, + // See createRemoteWorktree above: createdAt protects the newly-created + // worktree from ambient PTY bumps in other worktrees for CREATE_GRACE_MS. + createdAt: now, + baseRef: baseBranch, + ...(requestedDisplayName + ? { displayName: requestedDisplayName } + : shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) + ? { displayName: effectiveRequestedName } + : {}), ...(sparseDirectories.length > 0 ? { sparseDirectories, @@ -414,15 +499,28 @@ export async function createLocalWorktree( // adds 100ms+ to every create. invalidateAuthorizedRootsCache() + // Why: create user-configured symlinks from the primary checkout into the + // new worktree before any setup script runs, so scripts that reuse shared + // state (e.g. `node_modules`, `.env`) see the links already in place. + // Gated on the experimental flag so disabling the feature globally skips + // the work even when a repo still has paths configured. + if (settings.experimentalWorktreeSymlinks && repo.symlinkPaths && repo.symlinkPaths.length > 0) { + await createWorktreeSymlinks(repo.path, created.path, repo.symlinkPaths) + } + + // Why: the worktree's own `orca.yaml` (at the tip of the base branch) is + // authoritative for what runs post-creation. The repo-level trust already + // granted by the user in the pre-create flow covers execution of that + // script; we intentionally do not re-gate on content equality with the + // primary checkout's preview, because benign divergence (whitespace, + // comments, or any setup-script edit that has landed on the base branch + // but not yet been pulled into the primary checkout) was silently + // disabling setup with no UI signal. See #1280 for the original gate and + // the regression this replaced. let setup: CreateWorktreeResult['setup'] const setupScript = getEffectiveHooks(repo, worktreePath)?.scripts.setup - const setupMatchesPreview = setupScriptsMatch(repo, worktreePath, primarySetupScript) let shouldLaunchSetup = false - if (setupScript && !setupMatchesPreview) { - console.warn( - `[hooks] setup hook skipped for ${worktreePath}: worktree setup script differs from the primary checkout setup script shown to the user` - ) - } else if (setupScript) { + if (setupScript) { try { shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision) } catch (error) { @@ -450,9 +548,53 @@ export async function createLocalWorktree( } } + if (runtime && optimisticBase && optimisticFetchPromise) { + initialBaseStatus = { + repoId: repo.id, + worktreeId, + status: 'checking', + base: optimisticBase.base, + remote: optimisticBase.remote + } + runtime.emitWorktreeBaseStatus(initialBaseStatus) + // Why: record the reconcile token BEFORE the rev-parse await so a racing + // worktree remove during the await isn't a no-op (its + // clearOptimisticReconcileToken would then run before the token exists, + // letting the post-await record install a fresh token whose reconcile + // would re-populate base status for a worktree that no longer exists). + const token = runtime.recordOptimisticReconcileToken(worktreeId) + try { + const createdBaseSha = await readCommitSha(created.path, 'HEAD') + void runtime + .reconcileWorktreeBaseStatus({ + repoId: repo.id, + repoPath: repo.path, + worktreeId, + base: optimisticBase, + branchName, + createdBaseSha, + token, + fetchPromise: optimisticFetchPromise + }) + .catch((error) => { + console.warn(`[worktree-base-status] reconcile failed for ${worktreeId}:`, error) + }) + } catch (error) { + console.warn(`[worktree-base-status] failed to read created base for ${worktreeId}:`, error) + runtime.emitWorktreeBaseStatus({ + repoId: repo.id, + worktreeId, + status: 'unknown', + base: optimisticBase.base, + remote: optimisticBase.remote + }) + } + } + notifyWorktreesChanged(mainWindow, repo.id) return { worktree, - ...(setup ? { setup } : {}) + ...(setup ? { setup } : {}), + ...(initialBaseStatus ? { initialBaseStatus } : {}) } } diff --git a/src/main/ipc/worktree-symlinks.test.ts b/src/main/ipc/worktree-symlinks.test.ts new file mode 100644 index 00000000000..155655c159a --- /dev/null +++ b/src/main/ipc/worktree-symlinks.test.ts @@ -0,0 +1,227 @@ +import { + mkdtempSync, + mkdirSync, + writeFileSync, + statSync, + lstatSync, + readlinkSync, + rmSync, + symlinkSync, + existsSync +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createWorktreeSymlinks, removeWorktreeSymlinks } from './worktree-symlinks' + +describe('createWorktreeSymlinks', () => { + let root: string + let primary: string + let worktree: string + let warn: ReturnType + let error: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-symlinks-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary, { recursive: true }) + mkdirSync(worktree, { recursive: true }) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + error = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + error.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + it('symlinks a file from primary into the worktree at the same relative path', async () => { + writeFileSync(join(primary, '.env'), 'SECRET=1\n') + await createWorktreeSymlinks(primary, worktree, ['.env']) + + const linkStat = lstatSync(join(worktree, '.env')) + expect(linkStat.isSymbolicLink()).toBe(true) + expect(readlinkSync(join(worktree, '.env'))).toBe(join(primary, '.env')) + // Following the link yields the primary's contents. + expect(statSync(join(worktree, '.env')).isFile()).toBe(true) + }) + + it('symlinks a directory from primary into the worktree', async () => { + mkdirSync(join(primary, 'node_modules')) + writeFileSync(join(primary, 'node_modules', 'marker'), 'installed') + await createWorktreeSymlinks(primary, worktree, ['node_modules']) + + expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(true) + expect(statSync(join(worktree, 'node_modules', 'marker')).isFile()).toBe(true) + }) + + it('creates parent directories lazily for nested paths', async () => { + mkdirSync(join(primary, 'apps', 'web'), { recursive: true }) + writeFileSync(join(primary, 'apps', 'web', '.env'), 'X=1\n') + await createWorktreeSymlinks(primary, worktree, ['apps/web/.env']) + + expect(lstatSync(join(worktree, 'apps', 'web', '.env')).isSymbolicLink()).toBe(true) + }) + + it('skips entries whose source is missing in the primary checkout', async () => { + await createWorktreeSymlinks(primary, worktree, ['node_modules']) + // No link created, no throw. + expect(() => lstatSync(join(worktree, 'node_modules'))).toThrow() + expect(error).not.toHaveBeenCalled() + }) + + it('preserves a pre-existing target in the worktree (no clobber)', async () => { + writeFileSync(join(primary, '.env'), 'FROM_PRIMARY=1\n') + writeFileSync(join(worktree, '.env'), 'FROM_WORKTREE=1\n') + + await createWorktreeSymlinks(primary, worktree, ['.env']) + + // The pre-existing regular file stays; no symlink was created. + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false) + expect(statSync(join(worktree, '.env')).isFile()).toBe(true) + }) + + it('does not escape the primary checkout via a leading-slash path', async () => { + // Why: the helper strips leading slashes (so `/etc/passwd` becomes the + // relative `etc/passwd`). No file is created outside the worktree, and the + // resolved source — which falls inside `primary/etc/passwd` — is missing, + // so the entry is silently skipped rather than linking to `/etc/passwd`. + await createWorktreeSymlinks(primary, worktree, ['/etc/passwd']) + + expect(() => lstatSync(join(worktree, 'etc', 'passwd'))).toThrow() + expect(error).not.toHaveBeenCalled() + }) + + it('rejects parent-directory traversal', async () => { + await createWorktreeSymlinks(primary, worktree, ['../secrets']) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[worktree-symlinks] Skipping unsafe path "../secrets"') + ) + }) + + it('rejects nested traversal via ..', async () => { + await createWorktreeSymlinks(primary, worktree, ['safe/../../escape']) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[worktree-symlinks] Skipping unsafe path "safe/../../escape"') + ) + }) + + it('rejects traversal using backslash separators (Windows form)', async () => { + // Why: users configuring paths on Windows (or pasting a mixed-separator + // value) could bypass a POSIX-only split. The guard normalizes across + // `/` and `\` so `..\escape` and `foo\..\..\escape` both get rejected. + await createWorktreeSymlinks(primary, worktree, ['..\\escape']) + await createWorktreeSymlinks(primary, worktree, ['foo\\..\\..\\escape']) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[worktree-symlinks] Skipping unsafe path "..\\escape"') + ) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[worktree-symlinks] Skipping unsafe path "foo\\..\\..\\escape"') + ) + }) + + it('strips a leading slash then treats the remainder as a relative path', async () => { + writeFileSync(join(primary, '.env'), 'X=1\n') + await createWorktreeSymlinks(primary, worktree, ['/.env']) + + // Leading slash is stripped in the helper; the remaining `.env` is a valid relative path. + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(true) + expect(warn).not.toHaveBeenCalled() + }) + + it('skips empty and whitespace-only entries', async () => { + // The helper logs an "unsafe path" warn for these; nothing gets linked. + await createWorktreeSymlinks(primary, worktree, ['', ' ']) + expect(error).not.toHaveBeenCalled() + }) + + it('continues processing later entries after one fails', async () => { + writeFileSync(join(primary, '.env'), 'X=1\n') + writeFileSync(join(primary, 'config.json'), '{}') + + await createWorktreeSymlinks(primary, worktree, [ + '../escape', // rejected + 'missing-source', // no source, skipped + '.env', // succeeds + 'config.json' // succeeds + ]) + + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(true) + expect(lstatSync(join(worktree, 'config.json')).isSymbolicLink()).toBe(true) + }) + + it('is a no-op for an empty paths list', async () => { + await createWorktreeSymlinks(primary, worktree, []) + expect(warn).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + }) +}) + +describe('removeWorktreeSymlinks', () => { + let root: string + let primary: string + let worktree: string + let error: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-unlink-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary, { recursive: true }) + mkdirSync(worktree, { recursive: true }) + error = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + error.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + it('unlinks configured symlinks from the worktree', async () => { + writeFileSync(join(primary, '.env'), 'SECRET=1\n') + mkdirSync(join(primary, 'node_modules')) + symlinkSync(join(primary, '.env'), join(worktree, '.env'), 'file') + symlinkSync(join(primary, 'node_modules'), join(worktree, 'node_modules'), 'dir') + + await removeWorktreeSymlinks(worktree, ['.env', 'node_modules']) + + expect(existsSync(join(worktree, '.env'))).toBe(false) + expect(existsSync(join(worktree, 'node_modules'))).toBe(false) + // Source is untouched. + expect(statSync(join(primary, '.env')).isFile()).toBe(true) + expect(statSync(join(primary, 'node_modules')).isDirectory()).toBe(true) + }) + + it('leaves a regular file at the configured path alone', async () => { + // Why: a user who created a real file at `.env` (instead of symlinking) + // must not lose it just because `.env` is in the configured list. + writeFileSync(join(worktree, '.env'), 'USER_WROTE_THIS=1\n') + + await removeWorktreeSymlinks(worktree, ['.env']) + + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false) + expect(statSync(join(worktree, '.env')).isFile()).toBe(true) + }) + + it('ignores missing entries', async () => { + await removeWorktreeSymlinks(worktree, ['.env', 'node_modules']) + expect(error).not.toHaveBeenCalled() + }) + + it('rejects unsafe paths without touching the filesystem', async () => { + // Parent-dir traversal is silently skipped; no unlink attempted. + writeFileSync(join(root, 'outside-file'), 'DO_NOT_DELETE') + await removeWorktreeSymlinks(worktree, ['../outside-file']) + expect(existsSync(join(root, 'outside-file'))).toBe(true) + }) + + it('is a no-op for an empty paths list', async () => { + await removeWorktreeSymlinks(worktree, []) + expect(error).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktree-symlinks.ts b/src/main/ipc/worktree-symlinks.ts new file mode 100644 index 00000000000..d08ee8d536f --- /dev/null +++ b/src/main/ipc/worktree-symlinks.ts @@ -0,0 +1,111 @@ +import { symlink, mkdir, stat, lstat, unlink } from 'fs/promises' +import { dirname, isAbsolute, resolve } from 'path' + +/** Create filesystem symlinks from the primary checkout into a freshly-created + * worktree for each configured path. Failures on individual paths are logged + * and skipped so a missing/stale entry never blocks worktree creation. + * + * Each entry is interpreted relative to `primaryPath` and placed at the same + * relative location inside `worktreePath`. Nested paths (e.g. + * `apps/web/.env`) are supported — parent directories are created lazily. */ +export async function createWorktreeSymlinks( + primaryPath: string, + worktreePath: string, + paths: readonly string[] +): Promise { + for (const rawPath of paths) { + // Why: strip leading separators (both `/` and `\`) before the guard so + // Windows-style input like `\foo` is normalized the same way POSIX `/foo` + // is, and the traversal check below sees the already-relative form. + const rel = rawPath.trim().replace(/^[\\/]+/, '') + // Why: split on both separators so a Windows-authored `..\escape` is + // rejected the same way POSIX `../escape` is. `path.isAbsolute` catches + // drive-letter absolutes (`C:\...`); the split catches relative + // backslash traversal that `.split('/')` would otherwise miss. + if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) { + // Users can only configure paths relative to the repo root; absolute + // paths and `..` traversal are not supported. + console.warn(`[worktree-symlinks] Skipping unsafe path "${rawPath}"`) + continue + } + + const source = resolve(primaryPath, rel) + const target = resolve(worktreePath, rel) + + let sourceIsDirectory = false + try { + const s = await stat(source) + sourceIsDirectory = s.isDirectory() + } catch { + // Source doesn't exist in primary checkout — nothing to link to. This is + // a common case for fresh clones where `node_modules` hasn't been + // installed yet; silently skip rather than leaving a dangling symlink. + continue + } + + try { + // Why: if a file/dir already exists at the target location (e.g. + // git-tracked sibling with the same name), leave it alone rather than + // clobber something the user didn't mean to replace. Use `lstat` so a + // pre-existing symlink (including a broken one whose source has moved) + // is detected and skipped, rather than falling through to `symlink()` + // and failing with EEXIST. + await lstat(target) + continue + } catch { + // Target does not exist — proceed with symlink creation. + } + + try { + await mkdir(dirname(target), { recursive: true }) + // Why: Windows requires an explicit `type` ('dir' vs 'file' vs + // 'junction') for `fs.symlink`. On POSIX the argument is ignored, so + // passing it unconditionally is safe and removes a Windows-only + // failure mode when Node can't auto-detect from the source. + await symlink(source, target, sourceIsDirectory ? 'dir' : 'file') + } catch (error) { + console.error( + `[worktree-symlinks] Failed to symlink "${rel}" (${source} -> ${target}):`, + error + ) + } + } +} + +/** Remove previously-created symlinks from a worktree before deletion. + * + * Why: `git worktree remove` refuses to delete a worktree that has modified + * or untracked files. A symlink pointing at the primary's `node_modules` + * looks "untracked" to git, so users would hit "It has changed files. Use + * Force Delete" on every deletion once they've configured this feature. + * Unlink the known symlinks up front so the non-force path keeps working. + * + * Safety: only removes entries that are actually symbolic links. A regular + * file or directory at the same path is left alone — we never want to clobber + * something the user created that happens to share a name with a configured + * entry. Missing entries (ENOENT) are silently ignored. */ +export async function removeWorktreeSymlinks( + worktreePath: string, + paths: readonly string[] +): Promise { + for (const rawPath of paths) { + const rel = rawPath.trim().replace(/^[\\/]+/, '') + if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) { + continue + } + const target = resolve(worktreePath, rel) + try { + const s = await lstat(target) + if (!s.isSymbolicLink()) { + continue + } + } catch { + continue + } + try { + await unlink(target) + } catch (error) { + console.error(`[worktree-symlinks] Failed to unlink "${rel}" (${target}):`, error) + } + } +} diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 34ab0f83082..3129c3d9ca5 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -17,7 +17,6 @@ const { runHookMock, hasHooksFileMock, loadHooksMock, - setupScriptsMatchMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock } = vi.hoisted(() => ({ @@ -34,7 +33,6 @@ const { createIssueCommandRunnerScriptMock: vi.fn(), createSetupRunnerScriptMock: vi.fn(), shouldRunSetupForCreateMock: vi.fn(), - setupScriptsMatchMock: vi.fn(() => true), runHookMock: vi.fn(), hasHooksFileMock: vi.fn(), loadHooksMock: vi.fn(), @@ -80,8 +78,7 @@ vi.mock('../hooks', () => ({ loadHooks: loadHooksMock, runHook: runHookMock, hasHooksFile: hasHooksFileMock, - shouldRunSetupForCreate: shouldRunSetupForCreateMock, - setupScriptsMatch: setupScriptsMatchMock + shouldRunSetupForCreate: shouldRunSetupForCreateMock })) vi.mock('./worktree-logic', async (importOriginal) => { @@ -184,7 +181,20 @@ describe('registerWorktreeHandlers – Windows path handling', () => { ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard') listWorktreesMock.mockResolvedValue([]) - registerWorktreeHandlers(mainWindow as never, store as never, {} as never) + // Why: createLocalWorktree routes `git fetch` through + // `runtime.fetchRemoteWithCache` (§3.3 Lifecycle). Stub it for path tests. + const runtimeStub = { + resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null), + hasRemoteTrackingRef: vi.fn().mockResolvedValue(false), + isRemoteFetchFresh: vi.fn().mockResolvedValue(false), + getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }), + fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined), + emitWorktreeBaseStatus: vi.fn(), + recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), + reconcileWorktreeBaseStatus: vi.fn(), + clearOptimisticReconcileToken: vi.fn() + } + registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) it('accepts a newly created Windows worktree when git lists the same path with different separators', async () => { @@ -233,13 +243,9 @@ describe('registerWorktreeHandlers – Windows path handling', () => { isBare: false, isMainWorktree: false } - // Three calls: (1) worktrees:create finds the new worktree, - // (2) rebuildAuthorizedRootsCache enumerates worktrees for the repo, - // (3) worktrees:list enumerates worktrees again. - listWorktreesMock - .mockResolvedValueOnce([worktreeEntry]) - .mockResolvedValueOnce([worktreeEntry]) - .mockResolvedValueOnce([worktreeEntry]) + // Two calls: (1) worktrees:create finds the new worktree, + // (2) worktrees:list enumerates worktrees again. + listWorktreesMock.mockResolvedValueOnce([worktreeEntry]).mockResolvedValueOnce([worktreeEntry]) store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 123, displayName: 'Improve Dashboard' diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 7377f15cc46..1eb2149fa06 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -19,7 +19,6 @@ const { runHookMock, hasHooksFileMock, loadHooksMock, - setupScriptsMatchMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, gitExecFileAsyncMock @@ -38,7 +37,6 @@ const { createIssueCommandRunnerScriptMock: vi.fn(), createSetupRunnerScriptMock: vi.fn(), shouldRunSetupForCreateMock: vi.fn(), - setupScriptsMatchMock: vi.fn(() => true), runHookMock: vi.fn(), hasHooksFileMock: vi.fn(), loadHooksMock: vi.fn(), @@ -83,8 +81,7 @@ vi.mock('../hooks', () => ({ loadHooks: loadHooksMock, runHook: runHookMock, hasHooksFile: hasHooksFileMock, - shouldRunSetupForCreate: shouldRunSetupForCreateMock, - setupScriptsMatch: setupScriptsMatchMock + shouldRunSetupForCreate: shouldRunSetupForCreateMock })) vi.mock('./worktree-logic', async (importOriginal) => { @@ -138,6 +135,17 @@ describe('registerWorktreeHandlers', () => { setWorktreeMeta: vi.fn(), removeWorktreeMeta: vi.fn() } + let runtimeStub: { + resolveRemoteTrackingBase: ReturnType + hasRemoteTrackingRef: ReturnType + isRemoteFetchFresh: ReturnType + getOrStartRemoteFetch: ReturnType + fetchRemoteWithCache: ReturnType + emitWorktreeBaseStatus: ReturnType + recordOptimisticReconcileToken: ReturnType + reconcileWorktreeBaseStatus: ReturnType + clearOptimisticReconcileToken: ReturnType + } beforeEach(() => { for (const m of [ @@ -211,9 +219,9 @@ describe('registerWorktreeHandlers', () => { getDefaultBaseRefMock.mockReturnValue('origin/main') getBranchConflictKindMock.mockResolvedValue(null) getPRForBranchMock.mockResolvedValue(null) - // Why: createLocalWorktree now fires `git fetch` in the background via - // gitExecFileAsync. The default mock must return a resolved promise so - // the fire-and-forget `.catch()` chain doesn't trip on undefined. + // Why: createLocalWorktree can still hit legacy git fetch fallback in + // narrow unit harnesses. Return a resolved promise so catch/then chains + // don't trip on undefined. gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) getEffectiveHooksMock.mockReturnValue(null) shouldRunSetupForCreateMock.mockReturnValue(false) @@ -251,7 +259,22 @@ describe('registerWorktreeHandlers', () => { ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath) listWorktreesMock.mockResolvedValue([]) - registerWorktreeHandlers(mainWindow as never, store as never, {} as never) + // Why: createLocalWorktree routes `git fetch` through + // `runtime.fetchRemoteWithCache` (§3.3 Lifecycle). A minimal stub + // keeps these tests focused on create-flow semantics; the full + // cache behavior is covered by fetch-remote-cache.test.ts. + runtimeStub = { + resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null), + hasRemoteTrackingRef: vi.fn().mockResolvedValue(false), + isRemoteFetchFresh: vi.fn().mockResolvedValue(false), + getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }), + fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined), + emitWorktreeBaseStatus: vi.fn(), + recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), + reconcileWorktreeBaseStatus: vi.fn(), + clearOptimisticReconcileToken: vi.fn() + } + registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { @@ -290,6 +313,96 @@ describe('registerWorktreeHandlers', () => { }) }) + it('persists a sanitized artifact title as the worktree display name', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'abc123', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + displayName: ' Fix: dashboards\nfor PRs\u0000 ' + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + displayName: 'Fix: dashboards for PRs' + }) + ) + expect(result).toEqual({ + worktree: expect.objectContaining({ + displayName: 'Fix: dashboards for PRs' + }) + }) + }) + + it('does not await a cold fetch when the remote-tracking base exists locally', async () => { + const remoteBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + let resolveFetch!: () => void + const pendingFetch = new Promise<{ ok: true }>((resolve) => { + resolveFetch = () => resolve({ ok: true }) + }) + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) + runtimeStub.getOrStartRemoteFetch.mockReturnValue(pendingFetch) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'created-sha', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' }) + + const createPromise = handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) as Promise + + const result = await Promise.race([ + createPromise, + new Promise((resolve) => setTimeout(() => resolve('timed-out'), 0)) + ]) + expect(result).not.toBe('timed-out') + + expect(runtimeStub.getOrStartRemoteFetch).toHaveBeenCalledWith('/workspace/repo', 'origin') + expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled() + expect(runtimeStub.emitWorktreeBaseStatus).toHaveBeenCalledWith({ + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/improve-dashboard', + status: 'checking', + base: 'origin/main', + remote: 'origin' + }) + expect(runtimeStub.reconcileWorktreeBaseStatus).toHaveBeenCalledWith( + expect.objectContaining({ + createdBaseSha: 'created-sha', + fetchPromise: pendingFetch + }) + ) + expect(result).toEqual( + expect.objectContaining({ + initialBaseStatus: expect.objectContaining({ status: 'checking', base: 'origin/main' }) + }) + ) + resolveFetch() + }) + it('throws a clear error when no default base ref can be resolved', async () => { // Why: guard against regressing to a silent 'origin/main' fallback. When // getDefaultBaseRef returns null (e.g. a fresh repo with no origin/HEAD, @@ -372,6 +485,136 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() }) + it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => { + // Why: a worktree that exists on disk but has no persisted WorktreeMeta + // (e.g. a folder repo just added, or a pre-existing worktree in a + // newly-added git repo) would otherwise fall back to `lastActivityAt: 0` + // and rank dead last in the Recent sort. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/discovered-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue(undefined) + const stampedMeta = { lastActivityAt: 1_700_000_000_000 } + store.setWorktreeMeta.mockReturnValue(stampedMeta) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/discovered-wt', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + expect(listed[0]).toMatchObject({ + id: 'repo-1::/workspace/discovered-wt', + lastActivityAt: 1_700_000_000_000 + }) + }) + + it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => { + // Why: only the *first* discovery should stamp. Re-stamping on every list + // would overwrite real activity and reshuffle the sidebar on refresh. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(listed[0].lastActivityAt).toBe(42) + }) + + it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { + // Why: folder repos produce a synthetic worktree that flows through the + // same list path. Without the stamp, adding a folder puts its card at the + // bottom of Recent even though the user just added it. + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + } + ]) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + store.getWorktreeMeta.mockReturnValue(undefined) + store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + + await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/folder', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + }) + + it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => { + // Why: the stamping logic lives in both worktrees:list and worktrees:listAll. + // Without a dedicated test, a regression in the listAll loop would silently + // bury newly-discovered worktrees from the multi-repo sidebar view. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/discovered-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue(undefined) + store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + + const listed = (await handlers['worktrees:listAll'](null, undefined)) as { + id: string + lastActivityAt: number + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/discovered-wt', + expect.objectContaining({ lastActivityAt: expect.any(Number) }) + ) + expect(listed[0]).toMatchObject({ + id: 'repo-1::/workspace/discovered-wt', + lastActivityAt: 1_700_000_000_000 + }) + }) + it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => { // Why: `gh pr list` is network-bound and previously fired on every single // create, adding 1–3s to the happy path. We now only probe PR conflicts @@ -502,6 +745,39 @@ describe('registerWorktreeHandlers', () => { ) }) + it('launches setup even when primary and worktree orca.yaml scripts diverge', async () => { + // Why: regression for a silent skip introduced by the #1280 content-equality + // gate. Benign divergence (whitespace, comments, or any setup edit that + // landed on the base branch but not yet in the primary checkout) must not + // disable setup — repo-level trust already gates execution. + listWorktreesMock.mockResolvedValue(createdWorktreeList) + getEffectiveHooksMock.mockImplementation((_repo, worktreePath?: string) => ({ + scripts: { + setup: worktreePath ? 'pnpm worktree:setup # worktree' : 'pnpm worktree:setup' + } + })) + shouldRunSetupForCreateMock.mockReturnValue(true) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + '/workspace/improve-dashboard', + 'pnpm worktree:setup # worktree' + ) + expect(result).toEqual( + expect.objectContaining({ + setup: expect.objectContaining({ + runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh' + }) + }) + ) + }) + it('creates a sparse worktree and persists its sparse metadata', async () => { listWorktreesMock.mockResolvedValue([ { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 6789f441eb0..8257306e78f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -5,7 +5,13 @@ import { rm } from 'fs/promises' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' import { deleteWorktreeHistoryDir } from '../terminal-history' -import type { CreateWorktreeArgs, CreateWorktreeResult, WorktreeMeta } from '../../shared/types' +import type { + CreateWorktreeArgs, + CreateWorktreeResult, + GitWorktreeInfo, + Repo, + WorktreeMeta +} from '../../shared/types' import { removeWorktree } from '../git/worktree' import { gitExecFileAsync } from '../git/runner' import { getDefaultRemote } from '../git/repo' @@ -33,10 +39,61 @@ import { createRemoteWorktree, notifyWorktreesChanged } from './worktree-remote' -import { rebuildAuthorizedRootsCache, ensureAuthorizedRootsCache } from './filesystem-auth' +import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' import { getLocalPtyProvider } from './pty' +import { removeWorktreeSymlinks } from './worktree-symlinks' +import { track } from '../telemetry/client' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' +import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' + +// Why: worktrees discovered on disk (not created via Orca's UI) have no +// persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. +// That makes them sort to the bottom of "Recent" even though the user just +// added the repo / folder. Stamp discovery time the first time we see a +// worktree so its very existence counts as a recency signal. Subsequent +// list calls find the persisted meta and skip the stamp. +function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { + const existing = store.getWorktreeMeta(worktreeId) + if (existing) { + return existing + } + return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) +} + +const loggedUnavailableSshGitProviders = new Set() +const loggedWorktreeListFailures = new Set() + +function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { + if (keySet.has(key)) { + return + } + keySet.add(key) + if (error) { + console.warn(message, error) + } else { + console.warn(message) + } +} + +function rememberLocalWorktreeRoots( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): void { + if (repo.connectionId) { + return + } + // Why: worktrees:list already paid the `git worktree list` cost. Reusing + // that result keeps later git/file IPC validation from doing a second + // background scan that can trigger macOS folder-permission prompts. + registerWorktreeRootsForRepo(store, repo.id, [ + repo.path, + ...gitWorktrees.map((worktree) => worktree.path) + ]) +} export function registerWorktreeHandlers( mainWindow: BrowserWindow, @@ -58,10 +115,6 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('hooks:writeIssueCommand') ipcMain.handle('worktrees:listAll', async () => { - // Why: use ensureAuthorizedRootsCache (not rebuild) to avoid redundantly - // listing git worktrees when the cache is already fresh — the handler - // itself calls listWorktrees for every repo below. - await ensureAuthorizedRootsCache(store) const repos = store.getRepos() // Why: repos are listed in parallel so total time = slowest repo, not @@ -75,18 +128,39 @@ export function registerWorktreeHandlers( } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) return [] } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) gitWorktrees = await provider.listWorktrees(repo.path) } else { gitWorktrees = await listRepoWorktrees(repo) } + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` - const meta = store.getWorktreeMeta(worktreeId) + const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) return mergeWorktree(repo.id, gw, meta, repo.displayName) }) - } catch { + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: do NOT seed an empty success here. registerWorktreeRootsForRepo + // would mark this repo as registered and flip + // registeredWorktreeRootsDirty to false, which causes + // resolveRegisteredWorktreePath to permanently deny access to + // legitimate linked worktrees of this repo until something invalidates + // the cache. Leaving it unregistered keeps the cache dirty so the + // next access path can rebuild. return [] } }) @@ -96,38 +170,54 @@ export function registerWorktreeHandlers( }) ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { - // Why: use ensureAuthorizedRootsCache (not rebuild) to avoid redundantly - // listing git worktrees when the cache is already fresh — the handler - // itself calls listWorktrees below. - await ensureAuthorizedRootsCache(store) const repo = store.getRepo(args.repoId) if (!repo) { return [] } - let gitWorktrees - if (isFolderRepo(repo)) { - gitWorktrees = [createFolderWorktree(repo)] - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - // Why: when SSH is disconnected the provider is null. Return [] so the - // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) - // preserves its cached worktree list. This avoids a console error on every - // fetchAllWorktrees cycle while the connection is being (re-)established — - // worktrees will be properly populated when the SSH `connected` event fires - // and triggers a re-fetch. - if (!provider) { - return [] + try { + let gitWorktrees + if (isFolderRepo(repo)) { + gitWorktrees = [createFolderWorktree(repo)] + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + // Why: when SSH is disconnected the provider is null. Return [] so the + // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) + // preserves its cached worktree list. This avoids a console error on every + // fetchAllWorktrees cycle while the connection is being (re-)established — + // worktrees will be properly populated when the SSH `connected` event fires + // and triggers a re-fetch. + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return [] + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + gitWorktrees = await provider.listWorktrees(repo.path) + } else { + gitWorktrees = await listRepoWorktrees(repo) } - gitWorktrees = await provider.listWorktrees(repo.path) - } else { - gitWorktrees = await listRepoWorktrees(repo) + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return gitWorktrees.map((gw) => { + const worktreeId = `${repo.id}::${gw.path}` + const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) + return mergeWorktree(repo.id, gw, meta, repo.displayName) + }) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: see worktrees:listAll catch — seeding an empty-success result + // would poison the auth cache and block linked worktrees. + return [] } - return gitWorktrees.map((gw) => { - const worktreeId = `${repo.id}::${gw.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gw, meta, repo.displayName) - }) }) ipcMain.handle( @@ -141,12 +231,43 @@ export function registerWorktreeHandlers( throw new Error('Folder mode does not support creating worktrees.') } - // Remote repos route all git operations through the relay - if (repo.connectionId) { - return createRemoteWorktree(args, repo, store, mainWindow) + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + + let result: CreateWorktreeResult + try { + // Why: only wrap the helpers themselves. The pre-validation throws + // above (`Repo not found`, `Folder mode does not support creating + // worktrees`) signal IPC-shape bugs, not the user-visible + // git/filesystem failures the funnel cares about — bucketing them + // into `unknown` would pollute the failure taxonomy. + result = repo.connectionId + ? await createRemoteWorktree(args, repo, store, mainWindow) + : await createLocalWorktree(args, repo, store, mainWindow, runtime) + } catch (error) { + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error } - return createLocalWorktree(args, repo, store, mainWindow) + // Why: emit `workspace_created` only after the underlying create has + // resolved (the helpers throw on failure, so reaching this line means + // git-add succeeded — we deliberately do not also emit a separate + // `workspace_initialized`, see telemetry-plan.md§Deferred events). + // `from_existing_branch` is true iff the caller specified a non-empty + // baseBranch; an unspecified baseBranch means "branch from default + // HEAD", which is the not-from-existing-branch case. We never send + // the branch name itself. + track('workspace_created', { + source, + from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0, + ...getCohortAtEmit() + }) + + return result } ) @@ -297,6 +418,7 @@ export function registerWorktreeHandlers( throw new Error(`No git provider for connection "${repo.connectionId}"`) } await provider.removeWorktree(worktreePath, args.force) + runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) @@ -312,6 +434,15 @@ export function registerWorktreeHandlers( } } + // Why: `git worktree remove` (non-force) refuses to delete a worktree + // that has untracked files, and a symlink pointing into the primary + // checkout looks untracked to git. Unlink the user-configured symlinks + // first so the normal delete path keeps working — otherwise every + // deletion would require the Force Delete toast once the feature is on. + if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { + await removeWorktreeSymlinks(worktreePath, repo.symlinkPaths) + } + try { await removeWorktree(repo.path, worktreePath, args.force ?? false) } catch (error) { @@ -324,17 +455,19 @@ export function registerWorktreeHandlers( // list` continues to show the stale entry and the branch it had checked out // remains locked — other worktrees cannot check it out. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) + runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) - await rebuildAuthorizedRootsCache(store) + invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return } throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false)) } + runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) - await rebuildAuthorizedRootsCache(store) + invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) } diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts new file mode 100644 index 00000000000..3cb4f888e86 --- /dev/null +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -0,0 +1,209 @@ +/** + * Tests for boot-time pty-registry hydration. + * + * Why these scenarios: + * - Daemon offline → graceful degradation. The renderer-side merge + * fallback should still work; we just lose the coverage win for that + * boot. Hydrator must catch and log, not throw. + * - Pid-write ordering. `pty:spawn` is the authoritative writer; if it + * wrote pid=12345 before the boot pass ran, the boot pass must NOT + * clobber that with `pid: null` from a pre-publish daemon listSessions. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/types' +import type { SessionInfo } from '../daemon/types' +import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import type { Store } from '../persistence' +import type { hydrateLocalPtyRegistryAtBoot as HydrateFn } from './hydrate-local-pty-registry' +import type { + listRegisteredPtys as ListFn, + registerPty as RegisterFn, + unregisterPty as UnregisterFn +} from './pty-registry' + +// Why: the hydrator pulls the daemon provider through this module-level +// getter. Stubbing it lets us drive the offline / throwing / live paths +// without spinning up real sockets. +const getDaemonProviderMock = vi.fn() +vi.mock('../daemon/daemon-init', () => ({ + getDaemonProvider: () => getDaemonProviderMock() +})) + +// Why: the hydrator builds its worktreeId → connectionId map by calling +// listRepoWorktrees(repo) for every repo in the store. The git I/O is +// out of scope for this unit; mock returns whatever the test wants. +const listRepoWorktreesMock = vi.fn() +vi.mock('../repo-worktrees', () => ({ + listRepoWorktrees: (repo: unknown) => listRepoWorktreesMock(repo) +})) + +// Why: hydrateLocalPtyRegistryAtBoot accepts `Pick` and +// the hydrator only reads `id` + `connectionId` off each Repo, but Repo's +// required fields (path, displayName, badgeColor, addedAt) still have to be +// present at the type level. Filling them with placeholder values keeps the +// test schema-compliant without coupling to anything the hydrator doesn't +// touch. +function makeStore( + repos: { id: string; connectionId?: string | null }[] = [] +): Pick { + const built: Repo[] = repos.map((r) => ({ + id: r.id, + path: `/tmp/${r.id}`, + displayName: r.id, + badgeColor: '#000000', + addedAt: 0, + connectionId: r.connectionId ?? null + })) + return { getRepos: () => built } +} + +function makeProvider(sessions: SessionInfo[]): Pick { + return { + listSessions: vi.fn().mockResolvedValue(sessions) + } +} + +// Why: the module under test memoizes `hasHydrated` at module scope so it +// only runs the git/RPC pass once per process. The pty-registry module +// also stashes state in a module-level Map, so we have to load BOTH +// fresh together — otherwise the hydrator writes into one Map and the +// test reads from another. Dynamic import after vi.resetModules() returns +// a coherent pair. +async function loadFresh(): Promise<{ + hydrate: typeof HydrateFn + listRegisteredPtys: typeof ListFn + registerPty: typeof RegisterFn + unregisterPty: typeof UnregisterFn +}> { + vi.resetModules() + const hydrateMod = await import('./hydrate-local-pty-registry') + const registryMod = await import('./pty-registry') + return { + hydrate: hydrateMod.hydrateLocalPtyRegistryAtBoot, + listRegisteredPtys: registryMod.listRegisteredPtys, + registerPty: registryMod.registerPty, + unregisterPty: registryMod.unregisterPty + } +} + +describe('hydrateLocalPtyRegistryAtBoot', () => { + beforeEach(() => { + getDaemonProviderMock.mockReset() + listRepoWorktreesMock.mockReset() + }) + + it('no-op when daemon provider is null at first call (retries on later activation)', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + getDaemonProviderMock.mockReturnValue(null) + + await hydrate(makeStore([{ id: 'repo-a' }])) + + expect(listRegisteredPtys()).toHaveLength(0) + expect(listRepoWorktreesMock).not.toHaveBeenCalled() + + // Why: the design says the hasHydrated guard must stay false until a + // provider is obtained, so a later macOS dock re-activation can retry. + // Provider becomes available; second call should now perform the pass. + const provider = makeProvider([]) + getDaemonProviderMock.mockReturnValue(provider) + listRepoWorktreesMock.mockResolvedValue([]) + + await hydrate(makeStore([{ id: 'repo-a' }])) + + expect(provider.listSessions).toHaveBeenCalledTimes(1) + }) + + it('catches provider.listSessions rejection and does not throw', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const provider = { + listSessions: vi.fn().mockRejectedValue(new Error('socket EPIPE')) + } + getDaemonProviderMock.mockReturnValue(provider) + listRepoWorktreesMock.mockResolvedValue([{ path: '/local/Triton', isMainWorktree: true }]) + + // Why: the renderer-side step-2 merge fallback covers this case. The + // hydrator must not surface the failure to the caller — it logs and + // moves on. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect(hydrate(makeStore([{ id: 'repo-a' }]))).resolves.toBeUndefined() + warnSpy.mockRestore() + + expect(listRegisteredPtys()).toHaveLength(0) + }) + + it('does not clobber a pre-existing registry pid with a null pid from listSessions', async () => { + const { hydrate, listRegisteredPtys, registerPty } = await loadFresh() + + const ptyId = 'repo-a::/local/Triton@@deadbeef' + // Why: simulate the spawn-time path having already written the row + // with the real pid before the boot pass runs. The boot pass must + // skip rather than overwriting with a stale pid. + registerPty({ + ptyId, + worktreeId: 'repo-a::/local/Triton', + sessionId: ptyId, + paneKey: 'tab-1:1', + pid: 12345 + }) + + const provider = makeProvider([ + // pid is null — typical of a session whose daemon-side pid hasn't + // been published yet. If the hydrator unconditionally re-registered, + // the live row would degrade to pid: null and the collector would + // stop sampling it on the next tick. + { sessionId: ptyId, pid: null, cwd: '/local/Triton' } as unknown as SessionInfo + ]) + getDaemonProviderMock.mockReturnValue(provider) + listRepoWorktreesMock.mockResolvedValue([ + { path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true } + ]) + + await hydrate(makeStore([{ id: 'repo-a', connectionId: null }])) + + const entry = listRegisteredPtys().find((p) => p.ptyId === ptyId) + expect(entry).toBeDefined() + expect(entry!.pid).toBe(12345) + expect(entry!.paneKey).toBe('tab-1:1') + }) + + it('skips SSH sessions (repo with non-null connectionId)', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + + const ptyId = 'repo-ssh::/remote/Stingray@@feedface' + const provider = makeProvider([ + { sessionId: ptyId, pid: 999, cwd: '/remote/Stingray' } as unknown as SessionInfo + ]) + getDaemonProviderMock.mockReturnValue(provider) + listRepoWorktreesMock.mockResolvedValue([ + { path: '/remote/Stingray', head: '', branch: '', isBare: false, isMainWorktree: true } + ]) + + await hydrate(makeStore([{ id: 'repo-ssh', connectionId: 'ssh-conn-1' }])) + + // Why: SSH sessions execute on a remote host and their pids are not + // visible to the local process sampler. Mirrors the spawn-time gate + // around `registerPty` in `pty.ts`'s `pty:spawn` handler. + expect(listRegisteredPtys()).toHaveLength(0) + }) + + it('registers a local session whose worktree is in the store with the daemon-published pid', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + + const ptyId = 'repo-a::/local/Triton@@cafebabe' + const provider = makeProvider([ + { sessionId: ptyId, pid: 4242, cwd: '/local/Triton' } as unknown as SessionInfo + ]) + getDaemonProviderMock.mockReturnValue(provider) + listRepoWorktreesMock.mockResolvedValue([ + { path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true } + ]) + + await hydrate(makeStore([{ id: 'repo-a', connectionId: null }])) + + const entry = listRegisteredPtys().find((p) => p.ptyId === ptyId) + expect(entry).toBeDefined() + expect(entry!.pid).toBe(4242) + expect(entry!.worktreeId).toBe('repo-a::/local/Triton') + }) +}) diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts new file mode 100644 index 00000000000..bfa2d2d5052 --- /dev/null +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -0,0 +1,161 @@ +/** + * Boot-time hydration of `pty-registry` from the live daemon. + * + * Why: the registry is normally populated by the `pty:spawn` IPC + * handler. On warm reattach (a fresh Orca process bound to a + * still-running daemon), the renderer hasn't re-mounted every pane + * yet, so `pty:spawn` hasn't fired for those sessions and the memory + * collector's snapshot omits them. The renderer then unions in + * `pty.listSessions()` results with `hasLocalSamples: false`, which + * the chip predicate rendered as "REMOTE" — even though the sessions + * are local. + * + * This module fills the gap once at boot: ask the daemon for every live + * session, reattribute each one to its repo via the minted session-id + * format, and only register sessions whose repo has no `connectionId` + * (i.e. truly local). Truly remote (SSH) sessions stay out of the + * registry, mirroring the spawn-time gate (the `if (!args.connectionId)` block around the `registerPty` call in `src/main/ipc/pty.ts`). + */ + +import { getDaemonProvider } from '../daemon/daemon-init' +import { DaemonPtyRouter } from '../daemon/daemon-pty-router' +import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import type { SessionInfo } from '../daemon/types' +import { listRegisteredPtys, registerPty } from './pty-registry' +import { listRepoWorktrees } from '../repo-worktrees' +import { parsePtySessionId } from '../../shared/pty-session-id-format' +import type { Store } from '../persistence' + +// Why: `attachMainWindowServices` runs on every macOS dock re-activation +// (see `app.on('activate', ...)` in src/main/index.ts), so this module +// guards against re-running git I/O + daemon RPC after the first pass. +// Stays false until we actually have a daemon provider, so a boot where +// the daemon socket isn't up yet remains retry-eligible on later +// re-activations. +let hasHydrated = false + +/** + * Read the live daemon session list and register every local session + * the registry doesn't already know about. + * + * Once-per-process when the daemon is reachable on first call: + * `attachMainWindowServices` fires on every macOS dock re-activation, so + * the module-level `hasHydrated` guard ensures the git-worktree + * enumeration and `listSessions` daemon RPC only run on the first + * successful invocation. If the daemon is offline at first call (no + * provider yet), the function returns without flipping the flag so a + * later macOS re-activation can retry; once a provider is obtained the + * flag flips and subsequent calls are a no-op. + * + * Wrapped in `try/catch` because the daemon socket may be unreachable + * at boot (process not yet started, or just died); the renderer-side + * union still covers that case until the daemon comes back. Any failure + * here is a coverage degradation, not a correctness regression. + */ +export async function hydrateLocalPtyRegistryAtBoot(store: Pick): Promise { + try { + if (hasHydrated) { + return + } + const provider = getDaemonProvider() + if (!provider) { + // Why: leave hasHydrated false so a later activation (after the + // daemon comes up) can retry. + return + } + // Why: flip only once we have a provider — committed to either + // succeeding or failing on a daemon RPC. Retrying after an RPC + // throw uses the same socket and is unlikely to help; the + // renderer-side union still covers that case. + hasHydrated = true + + // Why: build a worktree-id → connectionId map so we can SSH-gate each + // session before registering. Live git enumeration matches the path + // shape used by `mintPtySessionId` (`${repoId}::${path}`). + const repos = store.getRepos() + const repoConnectionIdByWorktreeId = new Map() + + for (const repo of repos) { + const worktrees = await listRepoWorktrees(repo) + const connectionId = repo.connectionId ?? null + for (const wt of worktrees) { + const worktreeId = `${repo.id}::${wt.path}` + repoConnectionIdByWorktreeId.set(worktreeId, connectionId) + } + } + + // Why: SessionInfo is read through the adapter's listSessions() so we + // get the pid alongside each id. Routing through every adapter + // (current + legacy) keeps protocol coverage symmetric with the + // orphan-cleanup path. + const sessionInfos = await collectSessionInfos(provider) + + const alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId)) + + for (const info of sessionInfos) { + // Why: pid-write ordering — `pty:spawn` is the authoritative + // writer for in-session sessions; if that fired before this loop + // started, we must not overwrite a known-good pid with a stale one + // from listSessions(). Skip if the entry already exists. + if (alreadyRegistered.has(info.sessionId)) { + continue + } + const { worktreeId } = parsePtySessionId(info.sessionId) + if (!worktreeId) { + continue + } + // Why: SSH sessions must stay out of the registry — mirrors the + // spawn-time `if (!args.connectionId)` gate around `registerPty` in + // `src/main/ipc/pty.ts`. If the repo isn't in the store, skip the + // session: we can't prove it's local, and the renderer-side union + // still surfaces the session at the cost of a missing pid sample. + if (!repoConnectionIdByWorktreeId.has(worktreeId)) { + continue + } + if (repoConnectionIdByWorktreeId.get(worktreeId)) { + continue + } + registerPty({ + ptyId: info.sessionId, + worktreeId, + sessionId: info.sessionId, + paneKey: null, + pid: + typeof info.pid === 'number' && Number.isFinite(info.pid) && info.pid > 0 + ? info.pid + : null + }) + } + } catch (err) { + console.warn( + '[memory] Boot-time pty-registry hydration failed:', + err instanceof Error ? err.message : String(err) + ) + } +} + +async function collectSessionInfos( + provider: DaemonPtyRouter | DaemonPtyAdapter +): Promise { + // Why: the router fans `listSessions` out across current + legacy adapters + // so we get every protocol-version daemon's sessions; the bare-adapter + // fallback is only the in-process restart edge case. + const adapters: readonly DaemonPtyAdapter[] = + provider instanceof DaemonPtyRouter ? provider.getAllAdapters() : [provider] + const out: SessionInfo[] = [] + for (const adapter of adapters) { + try { + const sessions = await adapter.listSessions() + out.push(...sessions) + } catch (err) { + // Why: a single adapter failing should not abort hydration of the + // others — the current adapter and any legacy daemons each have + // their own socket and one being unreachable is normal. + console.warn( + '[memory] listSessions failed for one adapter during hydration:', + err instanceof Error ? err.message : String(err) + ) + } + } + return out +} diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index d28a0b51d56..9bbf4faf9d9 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -35,7 +35,7 @@ function buildMenuOptions() { onToggleAppearance: vi.fn(), getAppearanceState: vi.fn(() => ({ showTasksButton: true, - showTitlebarAgentActivity: true, + showTitlebarAppName: true, statusBarVisible: true })) } @@ -194,7 +194,7 @@ describe('registerAppMenu', () => { const options = buildMenuOptions() options.getAppearanceState.mockReturnValue({ showTasksButton: false, - showTitlebarAgentActivity: true, + showTitlebarAppName: true, statusBarVisible: true }) registerAppMenu(options) @@ -209,9 +209,7 @@ describe('registerAppMenu', () => { expect(tasksItem?.type).toBe('checkbox') expect(tasksItem?.checked).toBe(false) - const titlebarItem = appearanceSubmenu.find( - (item) => item.label === 'Show Titlebar Agent Activity' - ) + const titlebarItem = appearanceSubmenu.find((item) => item.label === 'Show Titlebar App Name') expect(titlebarItem?.checked).toBe(true) const statusBarItem = appearanceSubmenu.find((item) => item.label === 'Show Status Bar') @@ -229,8 +227,12 @@ describe('registerAppMenu', () => { appearanceSubmenu .find((item) => item.label === 'Show Tasks Button') ?.click?.({} as never, {} as never, {} as never) + appearanceSubmenu + .find((item) => item.label === 'Show Titlebar App Name') + ?.click?.({} as never, {} as never, {} as never) expect(options.onToggleAppearance).toHaveBeenCalledWith('showTasksButton') + expect(options.onToggleAppearance).toHaveBeenCalledWith('showTitlebarAppName') }) it('routes sidebar toggle items through their callbacks', () => { diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index d78a70d8c1b..61d51284ee7 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -2,7 +2,7 @@ import { BrowserWindow, Menu, app } from 'electron' export type AppearanceMenuState = { showTasksButton: boolean - showTitlebarAgentActivity: boolean + showTitlebarAppName: boolean statusBarVisible: boolean } @@ -181,10 +181,10 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { click: () => onToggleAppearance('showTasksButton') }, { - label: 'Show Titlebar Agent Activity', + label: 'Show Titlebar App Name', type: 'checkbox', - checked: appearance.showTitlebarAgentActivity, - click: () => onToggleAppearance('showTitlebarAgentActivity') + checked: appearance.showTitlebarAppName, + click: () => onToggleAppearance('showTitlebarAppName') } ] } diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index b1ed29622b5..80e311a0600 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -1,5 +1,20 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdtempSync, rmSync, readFileSync } from 'fs' +/* eslint-disable max-lines -- Why: this suite covers four orthogonal regimes + (plugin source, id guards, legacy per-PTY round-trip, and overlay mode for + user-set OPENCODE_CONFIG_DIR). Splitting them across files would scatter + tightly coupled fixtures (userData mock, hooks/overlay roots) and obscure + the docs/opencode-config-dir-collision.md regression matrix. */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -80,6 +95,21 @@ describe('OpenCode hook plugin source', () => { expect(source).toContain('cachedEndpointValues = null;') }) + it('forwards question.asked as AskUserQuestion so the pane flips to waiting', () => { + // Why: OpenCode exposes two separate plugin events for human-in-the-loop + // moments — `permission.asked` (blocks on tool approval) and + // `question.asked` (the agent called an ask-the-user tool). The plugin + // must forward both so the server-side normalizer can map each to + // `waiting` and render the red indicator. Dropping `question.asked` + // leaves the pane stuck in `working` while the agent is actually idle, + // waiting on a human reply — exactly the bug three other OpenCode + // integrations (cmux, t3code, open-vibe-island) all handle. + const source = _internals.getOpenCodePluginSource() + + expect(source).toContain('if (event.type === "question.asked")') + expect(source).toContain('await post("AskUserQuestion", event.properties || {});') + }) + it('guards endpoint-file parse warnings with a process-lifetime latch', () => { // Why: ENOENT is the normal pre-install case and must stay silent, but a // malformed/unreadable file (EACCES, EIO, parse error) would otherwise @@ -142,7 +172,7 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { // Why: the primitives above only prove the helpers work in isolation. This // suite exercises the public surface against a real filesystem so a future // regression — e.g. re-tightening the id guard or desyncing the path used by - // writePluginConfig vs clearPty — fails loudly. Before #1148 the service + // writeLegacyPluginConfig vs clearPty — fails loudly. Before #1148 the service // silently returned {} for daemon-shaped ids; these tests lock that in. const daemonSessionId = '50c010a2-bc8e-4eb1-8847-5812133ad6df::/Users/thebr/ghostx/workspaces/noqa/autoheal@@a1b2c3d4' @@ -164,8 +194,8 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { }) afterEach(() => { - const hooksRoot = join(userDataDir, 'opencode-hooks') - rmSync(hooksRoot, { recursive: true, force: true }) + rmSync(join(userDataDir, 'opencode-hooks'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'opencode-config-overlays'), { recursive: true, force: true }) }) it('writes OPENCODE_CONFIG_DIR for a daemon-shaped sessionId and installs the plugin file', () => { @@ -196,9 +226,24 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { it('buildPtyEnv returns {} for an unusable id and creates nothing on disk', () => { const service = new OpenCodeHookService() const hooksRoot = join(userDataDir, 'opencode-hooks') + const overlaysRoot = join(userDataDir, 'opencode-config-overlays') expect(service.buildPtyEnv('')).toEqual({}) expect(existsSync(hooksRoot)).toBe(false) + expect(existsSync(overlaysRoot)).toBe(false) + }) + + it('buildPtyEnv preserves a user-set OPENCODE_CONFIG_DIR when the id is unusable', () => { + // Why: defense-in-depth — if the bounds guard rejects the id, we still + // must not blow away the user's own OPENCODE_CONFIG_DIR. The status + // plugin is forfeited, but the user's plugins/auth/keymap keep loading. + const service = new OpenCodeHookService() + const userDir = mkdtempSync(join(tmpdir(), 'orca-opencode-userdir-')) + try { + expect(service.buildPtyEnv('', userDir)).toEqual({ OPENCODE_CONFIG_DIR: userDir }) + } finally { + rmSync(userDir, { recursive: true, force: true }) + } }) it('works end-to-end for a plain UUID id (non-daemon path)', () => { @@ -216,3 +261,249 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(existsSync(env.OPENCODE_CONFIG_DIR!)).toBe(false) }) }) + +describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () => { + // Why: locks in docs/opencode-config-dir-collision.md — when the user has + // their own OPENCODE_CONFIG_DIR (e.g. a company-wide opencode config repo), + // Orca must mirror it into a per-PTY overlay rather than `delete` its own + // injection (the prior bug) or overwrite the user's value (Superset's + // failure mode). The user's auth/models/keymap and Orca's status plugin + // both load via a single OPENCODE_CONFIG_DIR. + const ptyId = 'overlay-pty-1' + let userDataDir: string + let userConfigDir: string + + beforeAll(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-opencode-overlay-userdata-')) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected getPath(${name})`) + }) + }) + + afterAll(() => { + rmSync(userDataDir, { recursive: true, force: true }) + }) + + beforeEach(() => { + userConfigDir = mkdtempSync(join(tmpdir(), 'orca-opencode-overlay-userconfig-')) + // Realistic user config: top-level files plus a plugins/ dir with a user plugin. + writeFileSync(join(userConfigDir, 'opencode.json'), '{"userTheme":"solarized"}') + writeFileSync(join(userConfigDir, 'auth.json'), 'user-auth-token') + mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) + writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'export default () => {}') + }) + + afterEach(() => { + rmSync(userConfigDir, { recursive: true, force: true }) + rmSync(join(userDataDir, 'opencode-hooks'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'opencode-config-overlays'), { recursive: true, force: true }) + }) + + function expectUserConfigIntact(): void { + expect(readFileSync(join(userConfigDir, 'opencode.json'), 'utf8')).toBe( + '{"userTheme":"solarized"}' + ) + expect(readFileSync(join(userConfigDir, 'auth.json'), 'utf8')).toBe('user-auth-token') + expect(readFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'utf8')).toBe( + 'export default () => {}' + ) + } + + it('builds an overlay under userData and exposes user config + Orca plugin together', () => { + const service = new OpenCodeHookService() + const env = service.buildPtyEnv(ptyId, userConfigDir) + + expect(env.OPENCODE_CONFIG_DIR).toBe( + join(userDataDir, 'opencode-config-overlays', toSafeDirName(ptyId)) + ) + expect(env.OPENCODE_CONFIG_DIR).not.toBe(userConfigDir) + + // Mirrored user files reachable via the overlay. + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'opencode.json'), 'utf8')).toBe( + '{"userTheme":"solarized"}' + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'auth.json'), 'utf8')).toBe( + 'user-auth-token' + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'user-plugin.js'), 'utf8')).toBe( + 'export default () => {}' + ) + + // Orca's status plugin is a sibling, not a replacement. + const orcaPluginPath = join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js') + expect(existsSync(orcaPluginPath)).toBe(true) + expect(readFileSync(orcaPluginPath, 'utf8')).toContain('OrcaOpenCodeStatusPlugin') + + expectUserConfigIntact() + }) + + it.skipIf(process.platform === 'win32')( + 'mirrors top-level entries via symlinks so plugins/ is a real directory', + () => { + // Why: only the plugins/ subtree needs entry-by-entry mirroring so Orca + // can drop a sibling file alongside the user's plugins. Other top-level + // entries (auth.json, opencode.json) are mirrored as a single symlink so + // user edits propagate live on POSIX. + const service = new OpenCodeHookService() + const env = service.buildPtyEnv(ptyId, userConfigDir) + + const overlay = env.OPENCODE_CONFIG_DIR! + expect(lstatSync(join(overlay, 'opencode.json')).isSymbolicLink()).toBe(true) + expect(lstatSync(join(overlay, 'auth.json')).isSymbolicLink()).toBe(true) + // plugins/ must be a real directory in the overlay so Orca can write + // its sibling status plugin into it. + expect(lstatSync(join(overlay, 'plugins')).isDirectory()).toBe(true) + expect(lstatSync(join(overlay, 'plugins')).isSymbolicLink()).toBe(false) + // user-plugin.js inside plugins/ is mirrored entry-by-entry. + expect(lstatSync(join(overlay, 'plugins', 'user-plugin.js')).isSymbolicLink()).toBe(true) + } + ) + + it("does not overwrite a user plugin file with the same filename as Orca's plugin", () => { + // Why: the failure mode this guards against — a user-owned plugin file + // happens to be named orca-opencode-status.js. Without the per-entry + // skip in mirrorUserConfig, the file would be linked into the overlay + // and Orca's writeFileSync would write through the symlink, destroying + // the user's content on their real filesystem. + const userOrcaSentinel = 'USER OWNED ORCA-NAMED PLUGIN — DO NOT CLOBBER' + writeFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), userOrcaSentinel) + + const service = new OpenCodeHookService() + const env = service.buildPtyEnv(ptyId, userConfigDir) + + // User's source file must be untouched. + expect(readFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe( + userOrcaSentinel + ) + + // Overlay copy is Orca's real plugin source, not the user's file. + const overlayPlugin = readFileSync( + join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js'), + 'utf8' + ) + expect(overlayPlugin).toContain('OrcaOpenCodeStatusPlugin') + expect(overlayPlugin).not.toBe(userOrcaSentinel) + expectUserConfigIntact() + }) + + it.skipIf(process.platform === 'win32')( + 'does not write through a symlinked plugins/ directory into the user filesystem', + () => { + // Why: if plugins/ is a symlink (common dotfiles pattern), writing Orca's + // status plugin through it would land in the user's real filesystem — + // exactly the failure mode docs/opencode-config-dir-collision.md rejects. + const realPluginsDir = mkdtempSync(join(tmpdir(), 'orca-real-plugins-')) + try { + writeFileSync(join(realPluginsDir, 'real-plugin.js'), 'REAL USER PLUGIN') + + // Replace the userConfigDir/plugins dir created by beforeEach with a + // symlink pointing at the external "real" plugins dir. + rmSync(join(userConfigDir, 'plugins'), { recursive: true, force: true }) + symlinkSync(realPluginsDir, join(userConfigDir, 'plugins'), 'dir') + + const service = new OpenCodeHookService() + const env = service.buildPtyEnv(ptyId, userConfigDir) + + // The user's real filesystem must NOT receive Orca's status plugin. + expect(existsSync(join(realPluginsDir, 'orca-opencode-status.js'))).toBe(false) + // Overlay's plugins/ must be a real directory, not a symlink that + // would write through to the user's filesystem. + expect(lstatSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins')).isSymbolicLink()).toBe(false) + // Orca's status plugin lands in the overlay only. + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js')) + ).toBe(true) + // The user's sentinel plugin is reachable through the overlay (mirrored + // entry-by-entry after resolving the symlink target). + expect( + readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'real-plugin.js'), 'utf8') + ).toBe('REAL USER PLUGIN') + } finally { + rmSync(realPluginsDir, { recursive: true, force: true }) + } + } + ) + + it("preserves the user's OPENCODE_CONFIG_DIR when the path does not exist", () => { + // Why: typoed user path — overriding it with an Orca-owned dir would let + // Orca's status plugin "succeed" while silently hiding the user's typo. + // The design rejects that: leave the user's value alone and let OpenCode + // surface the typo on its own. + const service = new OpenCodeHookService() + const missingPath = join(tmpdir(), `orca-opencode-nope-${Date.now()}`) + expect(existsSync(missingPath)).toBe(false) + + const env = service.buildPtyEnv(ptyId, missingPath) + expect(env).toEqual({ OPENCODE_CONFIG_DIR: missingPath }) + // No overlay was created at the typo path. + expect(existsSync(missingPath)).toBe(false) + // No overlay dir under userData either. + expect(existsSync(join(userDataDir, 'opencode-config-overlays', toSafeDirName(ptyId)))).toBe( + false + ) + }) + + it("preserves the user's OPENCODE_CONFIG_DIR when the mirror step fails", async () => { + // Why: mock the shared mirrorEntry helper to throw on the first symlink + // (e.g. Windows without developer mode → EPERM). The hook service must + // catch and fall back to { OPENCODE_CONFIG_DIR: existingConfigDir } — + // the user's plugins/auth/models keep loading; only Orca's status plugin + // is forfeited. + const overlayMirror = await import('../pty/overlay-mirror') + const mirrorSpy = vi.spyOn(overlayMirror, 'mirrorEntry').mockImplementation(() => { + throw new Error('simulated EPERM on symlink') + }) + try { + const service = new OpenCodeHookService() + const env = service.buildPtyEnv(ptyId, userConfigDir) + expect(env).toEqual({ OPENCODE_CONFIG_DIR: userConfigDir }) + // Overlay dir was rolled back so a half-built tree does not leak. + const overlayDir = join(userDataDir, 'opencode-config-overlays', toSafeDirName(ptyId)) + expect(existsSync(overlayDir)).toBe(false) + expectUserConfigIntact() + } finally { + mirrorSpy.mockRestore() + } + }) + + it.skipIf(process.platform === 'win32')( + 'clearPty removes the overlay without following symlinks into the user dir', + () => { + // Critical regression guard from issue #1083: the overlay's mirrored + // entries are symlinks pointing at the user's real config. Recursive + // teardown must NEVER walk through them. + const service = new OpenCodeHookService() + service.buildPtyEnv(ptyId, userConfigDir) + + const overlayDir = join(userDataDir, 'opencode-config-overlays', toSafeDirName(ptyId)) + expect(existsSync(overlayDir)).toBe(true) + + service.clearPty(ptyId) + + expect(existsSync(overlayDir)).toBe(false) + // User config must still exist with all original contents. + expectUserConfigIntact() + // The plugins/ dir under the user config is also intact. + expect(readdirSync(join(userConfigDir, 'plugins'))).toEqual(['user-plugin.js']) + } + ) + + it('rebuilding the overlay for the same ptyId does not corrupt the user dir', () => { + // Mirrors the daemon cold-restore code path that calls buildPtyEnv with + // the same sessionId across restarts. Each rebuild must clear the prior + // overlay safely (no symlink-walk into user data) and produce a fresh + // overlay with both user files and Orca's plugin. + const service = new OpenCodeHookService() + service.buildPtyEnv(ptyId, userConfigDir) + service.buildPtyEnv(ptyId, userConfigDir) + const env = service.buildPtyEnv(ptyId, userConfigDir) + + expect( + readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js'), 'utf8') + ).toContain('OrcaOpenCodeStatusPlugin') + expectUserConfigIntact() + }) +}) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 5990fa667ae..074ed6720a0 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -4,10 +4,22 @@ runtime artifact and scatter tightly coupled string-template logic. */ import { app } from 'electron' import { join } from 'path' -import { mkdirSync, writeFileSync, rmSync } from 'fs' +import { + existsSync, + mkdirSync, + readdirSync, + realpathSync, + rmSync, + statSync, + unlinkSync, + writeFileSync +} from 'fs' import { createHash } from 'crypto' +import { mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' +const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' +const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' // Why: the id passed in by pty.ts's daemon path is a sessionId shaped like // "@@" where worktreeId itself contains "::" and a @@ -259,6 +271,18 @@ function getOpenCodePluginSource(): string { ' return;', ' }', '', + ' if (event.type === "question.asked") {', + ' // Why: question.asked fires when OpenCode uses an ask-the-user tool', + ' // (distinct from permission.asked, which blocks on tool approval).', + ' // The agent is idle-but-waiting on a human reply, not running, so we', + ' // must flip the pane to the same red "needs attention" state used for', + ' // permission requests. Like permission.asked, do not touch lastStatus', + ' // so the next SessionBusy/SessionIdle after the user answers still', + ' // fires and restores the normal working/done flow.', + ' await post("AskUserQuestion", event.properties || {});', + ' return;', + ' }', + '', ' if (event.type === "message.updated") {', ' // Why: role is already cached above the isChildSession await so the', ' // back-to-back message.part.updated for the same messageID is not', @@ -319,44 +343,169 @@ export class OpenCodeHookService { if (!isUsableId(ptyId)) { return } - // Why: writePluginConfig creates a directory per PTY under userData. - // Without cleanup these accumulate across sessions. Using getConfigDir - // keeps cleanup aligned with the path writePluginConfig created. - const configDir = this.getConfigDir(ptyId) + // Why: a PTY with this id may have been spawned under either regime — + // overlay (user had OPENCODE_CONFIG_DIR set) or legacy per-PTY (no user + // value). Both roots use the same hashed name, so try the overlay first + // via the safe-descend teardown that never follows symlinks/junctions + // into the user's source dir, then sweep any legacy directory left over + // from a prior code path. + safeRemoveOverlay(this.getOverlayDir(ptyId), this.getOverlayRoot()) try { - rmSync(configDir, { recursive: true, force: true }) + rmSync(this.getLegacyConfigDir(ptyId), { recursive: true, force: true }) } catch { - // Why: best-effort cleanup. The directory may already be gone if the user - // manually purged userData, or the OS may hold a lock briefly. + // Why: best-effort cleanup of the no-OPENCODE_CONFIG_DIR-set regime; + // there are no symlinks/junctions in this tree (Orca-only files), so + // rmSync recursive is safe here. } } - buildPtyEnv(ptyId: string): Record { - const configDir = this.writePluginConfig(ptyId) - if (!configDir) { - // Why: plugin config is best-effort. Returning an empty object lets the - // PTY spawn without the OpenCode plugin when the filesystem is locked; - // the agent-hooks env (ORCA_AGENT_HOOK_PORT/TOKEN/ORCA_PANE_KEY) is - // still injected separately by ipc/pty.ts so other agents keep working. - return {} - } - - // Why: OPENCODE_CONFIG_DIR points OpenCode at a plugin directory we own. - // Injecting it into every Orca PTY means manually launched `opencode` - // sessions automatically pick up the status plugin too, not just sessions - // started from a hardcoded command template. - return { OPENCODE_CONFIG_DIR: configDir } - } - - private getConfigDir(ptyId: string): string { - return join(app.getPath('userData'), 'opencode-hooks', toSafeDirName(ptyId)) - } - - private writePluginConfig(ptyId: string): string | null { + buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { if (!isUsableId(ptyId)) { - return null + // Why: defense-in-depth. If the id fails the bounds guard, a user-set + // OPENCODE_CONFIG_DIR should still be preserved so OpenCode loads the + // user's own config — only the Orca status plugin is forfeited. + return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {} } - const configDir = this.getConfigDir(ptyId) + + if (!existingConfigDir) { + // Why: no user value to mirror — keep the original per-PTY behavior so + // a manually launched `opencode` outside Orca's command templates picks + // up the status plugin via OPENCODE_CONFIG_DIR injection alone. + const configDir = this.writeLegacyPluginConfig(ptyId) + if (!configDir) { + return {} + } + return { OPENCODE_CONFIG_DIR: configDir } + } + + // Why: do NOT `mkdir -p` the user's typoed path — overriding it with an + // Orca-owned dir is the exact failure mode we reject Superset's wrapper + // for in docs/opencode-config-dir-collision.md. Let OpenCode surface the + // typo on its own; we only forfeit our status plugin for this pane. + if (!existsSync(existingConfigDir)) { + return { OPENCODE_CONFIG_DIR: existingConfigDir } + } + + const overlayDir = this.getOverlayDir(ptyId) + safeRemoveOverlay(overlayDir, this.getOverlayRoot()) + + try { + mkdirSync(overlayDir, { recursive: true }) + this.mirrorUserConfig(existingConfigDir, overlayDir) + this.writePluginIntoOverlay(overlayDir) + } catch { + // Why: overlay creation is best-effort. Symlink-creation can fail on + // Windows without developer mode (EPERM), userData can be read-only on + // locked-down corporate machines, etc. In every case, preserve the + // user's OPENCODE_CONFIG_DIR — a missing status plugin is a vastly + // smaller harm than silently dropping the user's auth/models/keymap. + this.clearPty(ptyId) + return { OPENCODE_CONFIG_DIR: existingConfigDir } + } + + return { OPENCODE_CONFIG_DIR: overlayDir } + } + + private getOverlayRoot(): string { + return join(app.getPath('userData'), OPENCODE_OVERLAY_DIR) + } + + private getOverlayDir(ptyId: string): string { + // Why: the overlay root is distinct from the legacy hooks root so the + // two regimes are easy to tell apart on disk during debugging. + return join(this.getOverlayRoot(), toSafeDirName(ptyId)) + } + + private getLegacyConfigDir(ptyId: string): string { + return join(app.getPath('userData'), OPENCODE_LEGACY_HOOKS_DIR, toSafeDirName(ptyId)) + } + + // Why: walks the user's OPENCODE_CONFIG_DIR top-level entries. The + // `plugins/` subdirectory gets created as a real directory in the overlay + // so Orca can drop a sibling file alongside the user's plugins; everything + // else (opencode.json, auth.json, themes/, etc.) is mirrored as a single + // top-level entry via symlink/junction so user edits propagate live on + // POSIX (and on Windows-with-developer-mode) without copying files. + private mirrorUserConfig(sourceDir: string, overlayDir: string): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + + if (entry.name === 'plugins') { + // Why: check isSymbolicLink BEFORE isDirectory — a Windows junction + // can report both as true on a Dirent, and we must take the symlink + // branch so the per-entry mirroring (not a single mirrorEntry call + // that would create a symlink at /plugins) handles it. + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + // Why: broken symlink (target missing) or permission error — fall + // through to the default mirrorEntry path so the dangling link is + // mirrored verbatim rather than write-through-resolved. + isLinkPointingToDir = false + } + } + + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + // Why: when the user's plugins/ is a symlink-to-dir, resolve to the + // real target so readdir returns the actual entries and child paths + // join against the resolved root. mirrorEntry then creates symlinks + // pointing into the resolved real plugins (not back through the + // user's link), and /plugins itself stays a real dir so + // writePluginIntoOverlay can never write through to the user's FS. + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + // Why: skip a user file with the same filename as Orca's plugin — + // mirroring it here would either resolve a same-named target via + // symlink (writePluginIntoOverlay then clobbers the user's file + // through the link) or collide on Windows with the directory entry + // about to be created by writePluginIntoOverlay. Either way the + // user's plugin would be lost. Skipping yields the desired + // semantics: Orca's status plugin runs and the user's same-named + // plugin is shadowed for this PTY only — their source file on disk + // is untouched. + if (pluginEntry.name === ORCA_OPENCODE_PLUGIN_FILE) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } + } + + // Why: write Orca's status plugin into the overlay's plugins/ dir. The + // pre-write unlink is the load-bearing part — POSIX writeFileSync over a + // symlink writes through to the link target, so without it a user-owned + // plugin with this filename would be clobbered through a mirrored link. + // Skipping the same-named user file in mirrorUserConfig already prevents + // the link from being created, but the unlink keeps this function safe + // even if a stale overlay slips through with the link still in place. + private writePluginIntoOverlay(overlayDir: string): void { + const pluginsDir = join(overlayDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const pluginPath = join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE) + try { + unlinkSync(pluginPath) + } catch { + // No-op: file may not exist on a fresh overlay. Any persistent failure + // (e.g. permissions) will surface on the writeFileSync below. + } + writeFileSync(pluginPath, getOpenCodePluginSource()) + } + + private writeLegacyPluginConfig(ptyId: string): string | null { + const configDir = this.getLegacyConfigDir(ptyId) const pluginsDir = join(configDir, 'plugins') try { mkdirSync(pluginsDir, { recursive: true }) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d658bb22835..0339bdecb7b 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -13,6 +13,17 @@ const testState = { dir: '' } vi.mock('electron', () => ({ app: { getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('encrypted:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('encrypted:'.length) + } } })) @@ -72,19 +83,21 @@ describe('Store', () => { expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(settings.theme).toBe('system') + expect(settings.appFontFamily).toBe('Geist') expect(settings.editorAutoSave).toBe(false) expect(settings.editorAutoSaveDelayMs).toBe(1000) expect(settings.terminalFontSize).toBe(14) expect(settings.terminalFontWeight).toBe(500) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) + expect(settings.notifications.customSoundPath).toBeNull() }) it('returns default UI state when no data file exists', async () => { const store = await createStore() const ui = store.getUI() expect(ui.sidebarWidth).toBe(280) - expect(ui.groupBy).toBe('none') + expect(ui.groupBy).toBe('repo') expect(ui.lastActiveRepoId).toBeNull() expect(ui.dismissedUpdateVersion).toBeNull() expect(ui.lastUpdateCheckAt).toBeNull() @@ -146,10 +159,36 @@ describe('Store', () => { expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) expect(store.getSettings().showTasksButton).toBe(true) + expect(store.getSettings().notifications.customSoundPath).toBeNull() // repos should be loaded expect(store.getRepos()).toHaveLength(1) }) + it('preserves custom notification sound paths from persisted settings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + notifications: { + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + } + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().notifications).toMatchObject({ + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true, + customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg' + }) + }) + it('preserves editorAutoSaveDelayMs when set in persisted data', async () => { writeDataFile({ schemaVersion: 1, @@ -310,12 +349,14 @@ describe('Store', () => { theme: 'dark', editorAutoSave: true, editorAutoSaveDelayMs: 1500, + appFontFamily: 'Inter', terminalFontSize: 16, terminalFontWeight: 600 }) expect(updated.theme).toBe('dark') expect(updated.editorAutoSave).toBe(true) expect(updated.editorAutoSaveDelayMs).toBe(1500) + expect(updated.appFontFamily).toBe('Inter') expect(updated.terminalFontSize).toBe(16) expect(updated.terminalFontWeight).toBe(600) // Other fields preserved @@ -403,7 +444,7 @@ describe('Store', () => { store.updateUI({ sidebarWidth: 400 }) const ui = store.getUI() expect(ui.sidebarWidth).toBe(400) - expect(ui.groupBy).toBe('none') // default preserved + expect(ui.groupBy).toBe('repo') // default preserved expect(ui.dismissedUpdateVersion).toBeNull() }) @@ -415,6 +456,36 @@ describe('Store', () => { expect(ui.lastUpdateCheckAt).toBe(1234) }) + it('encrypts the Kagi session link on disk and decrypts it on load', async () => { + const sessionLink = 'https://kagi.com/search?token=secret' + const store = await createStore() + + store.updateUI({ browserKagiSessionLink: sessionLink }) + store.flush() + + const persisted = readDataFile() as { ui: { browserKagiSessionLink: string } } + expect(persisted.ui.browserKagiSessionLink).not.toBe(sessionLink) + + const reloaded = await createStore() + expect(reloaded.getUI().browserKagiSessionLink).toBe(sessionLink) + }) + + it('keeps plaintext Kagi session links readable for migration from older builds', async () => { + const sessionLink = 'https://kagi.com/search?token=secret' + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { browserKagiSessionLink: sessionLink }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().browserKagiSessionLink).toBe(sessionLink) + }) + it('preserves persisted smart sort value', async () => { writeDataFile({ schemaVersion: 1, @@ -579,22 +650,38 @@ describe('Store', () => { expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) - // ── inline-agents card-property migration ────────────────────────── - // - // Why: 'inline-agents' was added to DEFAULT_WORKTREE_CARD_PROPERTIES after - // the experimentalAgentDashboard toggle. Users who had the toggle on in a - // prior rc already had worktreeCardProperties persisted without the new - // entry, so the defaults-merge in load() wouldn't reach them and the - // inline agent list stayed hidden after upgrade. The migration appends - // 'inline-agents' once and sets a flag so a later deliberate uncheck - // from the Workspaces view options menu sticks across restarts. - - it('adds inline-agents to persisted cardProps when experimental toggle is on', async () => { + it('migrates the legacy experimentalSidekick setting to experimentalPet', async () => { writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, - settings: { experimentalAgentDashboard: true }, + settings: { experimentalSidekick: true }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + + expect(store.getSettings().experimentalPet).toBe(true) + }) + + // ── inline-agents card-property migration ────────────────────────── + // + // Why: 'inline-agents' was added to DEFAULT_WORKTREE_CARD_PROPERTIES after + // the inline agents feature shipped default-on. Existing users had + // worktreeCardProperties persisted without the new entry, so the + // defaults-merge in load() wouldn't reach them and the inline agent list + // stayed hidden after upgrade. The migration appends 'inline-agents' once + // for every user and sets a flag so a later deliberate uncheck from the + // Workspaces view options menu sticks across restarts. + + it('adds inline-agents to persisted cardProps on first load after upgrade', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, ui: { worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'] }, @@ -604,28 +691,34 @@ describe('Store', () => { const store = await createStore() expect(store.getUI().worktreeCardProperties).toContain('inline-agents') expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true) + expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) }) - it('does not add inline-agents when experimental toggle is off', async () => { - // Why: the experimental toggle gates whether inline agents render at all, - // so there's no value in checking the view-mode option for opted-out users. + it('adds inline-agents for users who launched a prior RC with the experiment off', async () => { + // Why: the legacy flag _inlineAgentsDefaultedForExperiment was stamped + // unconditionally on every prior load, so opt-out RC users already have + // it set to true on disk. The default-on migration must NOT be gated on + // that legacy flag — it must use the new _inlineAgentsDefaultedForAllUsers + // flag instead. Without this test, the regression would re-appear if + // anyone tried to "consolidate" the two flags. writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, - settings: { experimentalAgentDashboard: false }, + settings: {}, ui: { - worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'] + worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'], + _inlineAgentsDefaultedForExperiment: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} }) const store = await createStore() - expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') - expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true) + expect(store.getUI().worktreeCardProperties).toContain('inline-agents') + expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) }) - it('respects a deliberate uncheck after migration flag is set', async () => { + it('respects a deliberate post-migration uncheck', async () => { // Why: once migrated, an empty-of-inline-agents array is treated as a // user choice — not a legacy pre-migration state — so we must not // re-add it on every subsequent launch. @@ -633,10 +726,10 @@ describe('Store', () => { schemaVersion: 1, repos: [], worktreeMeta: {}, - settings: { experimentalAgentDashboard: true }, + settings: {}, ui: { worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'], - _inlineAgentsDefaultedForExperiment: true + _inlineAgentsDefaultedForAllUsers: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} @@ -650,7 +743,7 @@ describe('Store', () => { schemaVersion: 1, repos: [], worktreeMeta: {}, - settings: { experimentalAgentDashboard: true }, + settings: {}, ui: { worktreeCardProperties: [ 'status', @@ -668,7 +761,78 @@ describe('Store', () => { const store = await createStore() const props = store.getUI().worktreeCardProperties expect(props.filter((p) => p === 'inline-agents')).toHaveLength(1) - expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true) + expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) + }) + + it('preserves a deliberate uncheck from the experimental-toggle era (Case B)', async () => { + // Why: a user who turned the experiment on and then deliberately + // unchecked 'inline-agents' from the sidebar options menu has the same + // on-disk shape as a never-touched user (legacy flag true, no + // 'inline-agents' in worktreeCardProperties). The migration discriminates + // them via the deprecated experimentalAgentDashboard value still riding + // on disk. Without this discriminator, the deliberate uncheck would be + // silently overridden on first load after upgrade. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { experimentalAgentDashboard: true }, + ui: { + worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'], + _inlineAgentsDefaultedForExperiment: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') + expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) + }) + + it('Case B preservation is durable across restarts', async () => { + // Why: once the new flag is stamped, the discriminator is no longer + // consulted. Subsequent loads must leave the deliberate uncheck intact + // even if a future settings-write code path were to strip the deprecated + // experimentalAgentDashboard key from disk. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { experimentalAgentDashboard: true }, + ui: { + worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'], + _inlineAgentsDefaultedForExperiment: true, + _inlineAgentsDefaultedForAllUsers: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') + }) + + it('lapsed Case B (experiment off at upgrade time) re-adds inline-agents', async () => { + // Why: documented limitation. A user who turned experiment on, unchecked, + // then turned the experiment off again before upgrading has + // experimentalAgentDashboard: false on disk. The discriminator only sees + // the most recent value, so they fall into the Case C path. They re-uncheck + // once and it sticks (new flag stamps). This test locks the limitation in + // so a future "fix" doesn't accidentally regress something else. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { experimentalAgentDashboard: false }, + ui: { + worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr', 'comment'], + _inlineAgentsDefaultedForExperiment: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getUI().worktreeCardProperties).toContain('inline-agents') + expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) }) // ── GitHub Cache ─────────────────────────────────────────────────── @@ -790,8 +954,7 @@ describe('Store', () => { telemetry: { optedIn: true, installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - existedBeforeTelemetryRelease: false, - firstRunNoticeShown: true + existedBeforeTelemetryRelease: false } }, ui: {}, @@ -802,8 +965,7 @@ describe('Store', () => { expect(store.getSettings().telemetry).toEqual({ optedIn: true, installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - existedBeforeTelemetryRelease: false, - firstRunNoticeShown: true + existedBeforeTelemetryRelease: false }) }) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 48f96569644..47a31c07a82 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -12,7 +12,10 @@ import type { Repo, SparsePreset, WorktreeMeta, - GlobalSettings + GlobalSettings, + OnboardingChecklistState, + OnboardingOutcome, + OnboardingState } from '../shared/types' import type { SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' @@ -20,9 +23,11 @@ import { getGitUsername } from './git/repo' import { getDefaultPersistedState, getDefaultNotificationSettings, + getDefaultOnboardingState, getDefaultUIState, getDefaultRepoHookSettings, - getDefaultWorkspaceSession + getDefaultWorkspaceSession, + ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' @@ -55,6 +60,14 @@ function decrypt(ciphertext: string): string { } } +function encryptOptionalSecret(value: string | null | undefined): string | null { + return value ? encrypt(value) : null +} + +function decryptOptionalSecret(value: string | null | undefined): string | null { + return value ? decrypt(value) : null +} + // Why: the data-file path must not be a module-level constant. Module-level // code runs at import time — before configureDevUserDataPath() redirects the // userData path in index.ts — so a constant would capture the default (non-dev) @@ -95,6 +108,88 @@ function normalizeSshTarget(t: SshTarget): SshTarget { return { ...t, configHost: t.configHost ?? t.label ?? t.host } } +// Why: shared by load-time merge and the IPC update handler so the same +// strict whitelist guards every entry into onboarding state — arbitrary +// renderer/disk input cannot inject unknown keys or wrong-typed values. +// Returns only validated fields; unknown keys are dropped silently. +// Why: returns Partial<...> with a partial checklist so the IPC update path +// merges over current state without wiping previously-true keys. Invalid +// top-level fields are OMITTED (not coerced to fallbacks) so partial updates +// don't clobber valid persisted state; the load-path caller spreads defaults. +export function sanitizeOnboardingUpdate( + input: unknown +): Partial> & { checklist?: Partial } { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return {} + } + const raw = input as Record + const out: Partial> & { + checklist?: Partial + } = {} + + if ('closedAt' in raw) { + // Why: `typeof raw.closedAt === 'number'` would let NaN/Infinity through; + // JSON.stringify writes those as `null` on save, which silently reverts + // closedAt and re-opens the wizard on next load. Require a finite, + // non-negative timestamp so live state matches what disk can persist. + if (typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) && raw.closedAt >= 0) { + out.closedAt = raw.closedAt + } else if (raw.closedAt === null) { + out.closedAt = null + } + // else: omit — preserve existing persisted value on merge. + } + if ('outcome' in raw) { + const v = raw.outcome + if (v === 'completed' || v === 'dismissed') { + out.outcome = v as OnboardingOutcome + } else if (v === null) { + out.outcome = null + } + // else: omit. + } + if ('lastCompletedStep' in raw) { + const v = raw.lastCompletedStep + if (typeof v === 'number' && Number.isInteger(v) && v >= -1 && v <= ONBOARDING_FINAL_STEP) { + out.lastCompletedStep = v + } + // else: omit. + } + if ('checklist' in raw) { + const rawChecklist = raw.checklist + if (rawChecklist && typeof rawChecklist === 'object' && !Array.isArray(rawChecklist)) { + // Why: copy ONLY caller-sent boolean keys so partial updates (e.g. + // `{ addedRepo: true }`) don't reset other checklist items to false. + const defaults = getDefaultOnboardingState().checklist + const rc = rawChecklist as Record + const checklist: Partial = {} + for (const key of Object.keys(defaults) as (keyof OnboardingChecklistState)[]) { + if (key in rc && typeof rc[key] === 'boolean') { + checklist[key] = rc[key] as boolean + } + } + out.checklist = checklist + } + } + return out +} + +// Why: read a settings field that was removed from the GlobalSettings type +// but still round-trips on disk via the ...parsed.settings spread. One-shot +// use only — for the inline-agents default-on migration's Case B discriminator. +// Delete with the migration in the cleanup release (2+ stable releases after +// _inlineAgentsDefaultedForAllUsers ships). +function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean { + return ( + (parsed?.settings as { experimentalAgentDashboard?: boolean } | undefined) + ?.experimentalAgentDashboard === true + ) +} + +function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | undefined { + return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick +} + export class Store { private state: PersistedState private writeTimer: ReturnType | null = null @@ -128,6 +223,9 @@ export class Store { if (parsed.settings?.opencodeSessionCookie) { parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie) } + if (parsed.ui?.browserKagiSessionLink) { + parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink) + } // Merge with defaults in case new fields were added const defaults = getDefaultPersistedState(homedir()) @@ -157,6 +255,10 @@ export class Store { settings: { ...defaults.settings, ...parsed.settings, + // Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry + // the old persisted flag forward once so enabled users don't lose it. + experimentalPet: + parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, terminalMacOptionAsAlt: migratedOptionAsAlt, terminalMacOptionAsAltMigrated: true, notifications: { @@ -175,24 +277,42 @@ export class Store { const sort = normalizeSortBy(rawSort) const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' // Why: the 'inline-agents' card property was added after the - // experimentalAgentDashboard toggle. Users who had the toggle on - // in a prior rc already had worktreeCardProperties persisted - // without the new entry, so a simple defaults merge wouldn't - // reach them and the inline agent list stayed hidden after - // upgrade. One-shot append 'inline-agents' to their persisted - // array when the experimental toggle is true; the flag prevents - // re-firing so a deliberate uncheck from the Workspaces view - // options menu sticks across restarts. - // The flag is stamped on every successful load — including when - // the experiment is off — so that a later flip-on is handled by - // the renderer's ExperimentalPane handler rather than re-firing - // this migration. + // feature shipped behind an experimental toggle. Now that the + // feature is default-on for everyone, every existing user needs + // 'inline-agents' appended to their persisted + // worktreeCardProperties on first load after upgrade so the + // inline agent rows render without further opt-in. A flag + // prevents re-firing so a deliberate uncheck from the Workspaces + // view options menu sticks across restarts. + // + // TRAP — do not key this on `_inlineAgentsDefaultedForExperiment`. + // That legacy flag was stamped unconditionally on every successful + // load() in prior builds, regardless of whether the experiment was + // toggled on. Every prior-RC user therefore already has it set to + // true on disk, including the opt-out cohort this widened + // migration was specifically written to reach. Gating on the + // legacy flag would silently skip exactly those users. The + // dedicated `_inlineAgentsDefaultedForAllUsers` flag exists so + // the new default-on migration can distinguish "already migrated + // under the new rules" from "happened to launch a prior build". + // + // Case B preservation: a user who turned the experiment on and then + // deliberately unchecked 'inline-agents' from the sidebar options + // menu has the same on-disk shape as a never-touched user. The + // discriminator below reads the deprecated `experimentalAgentDashboard` + // value as a one-shot signal. Both branches of the migration stamp + // `_inlineAgentsDefaultedForAllUsers`, so subsequent launches don't + // depend on the deprecated value continuing to round-trip. const rawCardProps = parsed.ui?.worktreeCardProperties - const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForExperiment === true - const experimentOn = parsed.settings?.experimentalAgentDashboard === true + const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true + const hadExperimentOn = readDeprecatedExperimentFlag(parsed) + const deliberateUncheck = + hadExperimentOn && + Array.isArray(rawCardProps) && + !rawCardProps.includes('inline-agents') const needsInlineAgentsMigration = !inlineAgentsMigrated && - experimentOn && + !deliberateUncheck && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents') const migratedCardProps = @@ -207,7 +327,11 @@ export class Store { ...(migratedCardProps !== undefined ? { worktreeCardProperties: migratedCardProps } : {}), - _inlineAgentsDefaultedForExperiment: true + // Why: keep stamping the legacy flag for forward-compat with + // a rollback to a pre-default-on build that still reads it. + // The new flag is the one that actually gates the migration. + _inlineAgentsDefaultedForExperiment: true, + _inlineAgentsDefaultedForAllUsers: true } })(), // Why: the workspace session is the most volatile persisted surface @@ -231,7 +355,37 @@ export class Store { } return { ...defaults.workspaceSession, ...result.value } })(), - sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget) + sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + onboarding: (() => { + // Why: if we successfully parsed an existing orca-data.json that + // lacks an onboarding block, this is an upgrade-cohort user — + // backfill as completed (not dismissed) so they don't get dropped + // into the wizard regardless of whether they currently have repos, + // SSH targets, or just non-default settings. Analytics still + // distinguish this from users who explicitly bailed mid-funnel. + if (!parsed.onboarding) { + return { + ...defaults.onboarding, + closedAt: Date.now(), + outcome: 'completed' as const, + lastCompletedStep: ONBOARDING_FINAL_STEP + } + } + // Why: validate every persisted onboarding key explicitly via the + // shared sanitizer instead of spreading raw values. A type-flipped + // field on disk (string where number expected, unknown checklist + // key) is dropped or coerced to the default rather than poisoning + // in-memory state. + const sanitized = sanitizeOnboardingUpdate(parsed.onboarding) + return { + ...defaults.onboarding, + ...sanitized, + checklist: { + ...defaults.onboarding.checklist, + ...sanitized.checklist + } + } + })() } } } catch (err) { @@ -253,13 +407,13 @@ export class Store { // One-shot telemetry cohort migration. Runs on every `load()` but is a // no-op once `existedBeforeTelemetryRelease` is set, so subsequent launches // pay only the property lookup. Populates: - // - `existedBeforeTelemetryRelease` — cohort discriminator (drives the - // first-launch toast vs. banner in PR 3). + // - `existedBeforeTelemetryRelease` — cohort discriminator (drives + // whether the existing-user opt-in banner is shown in PR 3; + // new users get no first-launch surface). // - `optedIn` — new users start opted in; existing users are `null` until // the banner resolves (the consent resolver returns `pending_banner` // until then, so nothing transmits). - // - `installId` — anonymous UUID v4. Stable across launches; regenerable - // from the Privacy pane (PR 3). + // - `installId` — anonymous UUID v4. Stable across launches; not surfaced in the UI. private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { const existing = state.settings?.telemetry // Why: the one-shot is complete only when all three invariants hold. @@ -274,16 +428,22 @@ export class Store { ) { return state } + // Why: cohort is the authoritative discriminator per invariant #8, so + // resolve it once and reuse it below — the `optedIn` fallback must not + // re-infer cohort from `fileExistedOnLoad` or field presence, or a + // partially-written telemetry block could land a new user in the + // existing-user `pending_banner` state. + const resolvedExistedBefore = + typeof existing?.existedBeforeTelemetryRelease === 'boolean' + ? existing.existedBeforeTelemetryRelease + : fileExistedOnLoad return { ...state, settings: { ...state.settings, telemetry: { ...existing, - existedBeforeTelemetryRelease: - typeof existing?.existedBeforeTelemetryRelease === 'boolean' - ? existing.existedBeforeTelemetryRelease - : fileExistedOnLoad, + existedBeforeTelemetryRelease: resolvedExistedBefore, // Why: preserve an explicit opt-in/out if the user has ever resolved // it. Only fall back to the cohort default (new users: on; existing // users: undecided until the first-launch banner resolves) when @@ -291,7 +451,7 @@ export class Store { optedIn: existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null ? existing.optedIn - : fileExistedOnLoad + : resolvedExistedBefore ? null : true, installId: @@ -342,6 +502,10 @@ export class Store { settings: { ...this.state.settings, opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie) + }, + ui: { + ...this.state.ui, + browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink) } } @@ -383,6 +547,10 @@ export class Store { settings: { ...this.state.settings, opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie) + }, + ui: { + ...this.state.ui, + browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink) } } @@ -411,6 +579,16 @@ export class Store { return this.state.repos.map((repo) => this.hydrateRepo(repo)) } + /** + * O(1) read of the persisted repo count. Use this when you only need the + * count (e.g. cohort-classifier) — `getRepos()` hydrates each repo and + * may run a synchronous git subprocess via `getGitUsername()`, which is + * wasteful when the caller only reads `.length`. + */ + getRepoCount(): number { + return this.state.repos.length + } + getRepo(id: string): Repo | undefined { const repo = this.state.repos.find((r) => r.id === id) return repo ? this.hydrateRepo(repo) : undefined @@ -421,6 +599,38 @@ export class Store { this.scheduleSave() } + // Why: returns false on a stale permutation (concurrent add/remove races + // the renderer's drag) so the caller can tell the renderer to resync rather + // than persist an order that drops or duplicates ids. + reorderRepos(orderedIds: string[]): boolean { + const current = this.state.repos + if (orderedIds.length !== current.length) { + return false + } + const seen = new Set() + for (const id of orderedIds) { + if (typeof id !== 'string' || seen.has(id)) { + return false + } + seen.add(id) + } + const byId = new Map() + for (const r of current) { + byId.set(r.id, r) + } + const next: Repo[] = [] + for (const id of orderedIds) { + const repo = byId.get(id) + if (!repo) { + return false + } + next.push(repo) + } + this.state.repos = next + this.scheduleSave() + return true + } + removeRepo(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) // Why: presets are repo-scoped, so removing the repo means the presets @@ -594,6 +804,38 @@ export class Store { this.scheduleSave() } + // ── Onboarding ──────────────────────────────────────────────────── + + getOnboarding(): PersistedState['onboarding'] { + const defaults = getDefaultOnboardingState() + return { + ...defaults, + ...this.state.onboarding, + checklist: { + ...defaults.checklist, + ...this.state.onboarding?.checklist + } + } + } + + updateOnboarding( + updates: Partial> & { + checklist?: Partial + } + ): PersistedState['onboarding'] { + const current = this.getOnboarding() + this.state.onboarding = { + ...current, + ...updates, + checklist: { + ...current.checklist, + ...updates.checklist + } + } + this.scheduleSave() + return this.getOnboarding() + } + // ── GitHub Cache ────────────────────────────────────────────────── getGitHubCache(): PersistedState['githubCache'] { @@ -612,10 +854,97 @@ export class Store { } setWorkspaceSession(session: PersistedState['workspaceSession']): void { + // Why: closes the second half of the SIGKILL race (Issue #217). The + // renderer's debounced session writer captures its state BEFORE pty:spawn + // returns, so the snapshot it later flushes via session:set has no + // tab.ptyId / ptyIdsByLeafId for the just-spawned PTY. If that stale + // snapshot lands AFTER persistPtyBinding's sync flush, it would overwrite + // the durable binding and re-open the orphan window. Merge in any + // existing bindings whenever the incoming snapshot's binding is empty. + const prior = this.state.workspaceSession + if (session && prior) { + const priorTabs = prior.tabsByWorktree ?? {} + const nextTabs = session.tabsByWorktree ?? {} + for (const [worktreeId, tabs] of Object.entries(nextTabs)) { + const priorList = priorTabs[worktreeId] + if (!priorList) { + continue + } + for (const tab of tabs) { + if (tab.ptyId) { + continue + } + const priorTab = priorList.find((t) => t.id === tab.id) + if (priorTab?.ptyId) { + tab.ptyId = priorTab.ptyId + } + } + } + const priorLayouts = prior.terminalLayoutsByTabId ?? {} + const nextLayouts = session.terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(nextLayouts)) { + const priorLayout = priorLayouts[tabId] + if (!priorLayout?.ptyIdsByLeafId) { + continue + } + const incoming = layout.ptyIdsByLeafId + if (incoming && Object.keys(incoming).length > 0) { + continue + } + layout.ptyIdsByLeafId = { ...priorLayout.ptyIdsByLeafId } + } + } this.state.workspaceSession = session this.scheduleSave() } + // Why: closes the SIGKILL-between-spawn-and-persist race (Issue #217). The + // renderer's debounced session writer (~450 ms total) is normally the only + // path that writes tab.ptyId / ptyIdsByLeafId; a force-quit inside that + // window orphans the daemon's history dir. Patching + sync flushing here + // before pty:spawn returns guarantees the renderer cannot observe a + // spawn-success without the binding already being durable on disk. + persistPtyBinding(args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + }): void { + const session = this.state.workspaceSession + if (!session) { + return + } + const tabs = session.tabsByWorktree?.[args.worktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = args.ptyId + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + // Why: first-spawn-ever for a new tab — the renderer's debounced writer + // creates the layout entry on PaneManager init, but the binding has to + // be on disk before pty:spawn returns or a SIGKILL inside the same + // window would lose ptyIdsByLeafId for split-pane cold restore. The + // renderer will overwrite this minimal layout once persistLayoutSnapshot + // fires. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + this.flush() + } + // ── SSH Targets ──────────────────────────────────────────────────── getSshTargets(): SshTarget[] { diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts new file mode 100644 index 00000000000..0affcf089bf --- /dev/null +++ b/src/main/pi/agent-status-extension-source.ts @@ -0,0 +1,187 @@ +// Why: pi has no settings.json hook surface — its extensibility is the +// in-process TypeScript extension API (pi.on('agent_start'), 'tool_call', +// etc.). To get pi panes into the unified agent-hooks pipeline alongside +// Claude/Codex/Gemini/OpenCode/Cursor, we ship a bundled extension into +// the per-PTY Pi overlay (PiTitlebarExtensionService) that POSTs to +// /hook/pi using the same ORCA_AGENT_HOOK_* + ORCA_PANE_KEY env that every +// PTY already receives from ipc/pty.ts. +// +// The overlay is per-PTY, so each pi process boots with its own copy of +// this extension and its own paneKey. Like the OpenCode plugin, the +// returned source is a string (loaded by jiti from disk inside the pi +// process), so we keep the source body in plain JS without TS types and +// avoid pulling pi or any Orca dep into the pi runtime. +export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' + +export function getPiAgentStatusExtensionSource(): string { + // Why: keep this string self-contained — it runs inside the pi process, + // so it cannot import from Orca's main bundle. fs/http coords come from + // the same endpoint file the OpenCode plugin reads (process.env is frozen + // at PTY spawn, so on Orca restart we have to re-read it from disk). + return [ + "import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'", + '', + '// Why: warn-once so a recurring parse error on a malformed endpoint', + '// file does not spam stderr inside the pi TUI on every event.', + 'let warnedBadEndpoint = false', + '', + '// Why: re-reading the endpoint file on every event is cheap (small file,', + '// rare changes) but stat+mtime caching avoids re-parsing on every event', + '// during streaming tool execution. Mirrors the OpenCode plugin cache shape.', + "let cachedEndpointKey = ''", + 'let cachedEndpointValues: Record | null = null', + '', + 'function readEndpointFile(): Record | null {', + ' const path = process.env.ORCA_AGENT_HOOK_ENDPOINT', + ' if (!path) return null', + ' try {', + " const fs = require('fs')", + ' try {', + ' const stat = fs.statSync(path)', + " const cacheKey = stat.mtimeMs + ':' + stat.size + ':' + stat.ino", + ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', + ' return cachedEndpointValues', + ' }', + " const contents: string = fs.readFileSync(path, 'utf8')", + ' const out: Record = {}', + ' for (const line of contents.split(/\\r?\\n/)) {', + ' // Why: parse `KEY=VALUE` (POSIX endpoint.env) and `set KEY=VALUE`', + ' // (Windows endpoint.cmd) with one regex; strip a trailing CR so', + ' // mixed-EOL files do not leak \\r into the value.', + ' const m = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/)', + " if (m) out[m[1]] = m[2].replace(/\\r$/, '')", + ' }', + ' cachedEndpointKey = cacheKey', + ' cachedEndpointValues = out', + ' return out', + ' } catch (ioErr) {', + " cachedEndpointKey = ''", + ' cachedEndpointValues = null', + ' throw ioErr', + ' }', + ' } catch (err: unknown) {', + ' const code = (err as { code?: string } | null)?.code', + " if (err && code !== 'ENOENT' && !warnedBadEndpoint) {", + ' warnedBadEndpoint = true', + " console.warn('[orca-pi-status] failed to parse endpoint file:', (err as Error).message)", + ' }', + ' return null', + ' }', + '}', + '', + 'function resolveHookCoords() {', + ' const fileEnv = readEndpointFile() || {}', + ' return {', + ' port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,', + ' token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN,', + " env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || '',", + " version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || '',", + ' }', + '}', + '', + 'async function post(hookEventName: string, extra: Record = {}): Promise {', + ' const coords = resolveHookCoords()', + ' const paneKey = process.env.ORCA_PANE_KEY', + ' if (!coords.port || !coords.token || !paneKey) return', + ' const url = `http://127.0.0.1:${coords.port}/hook/pi`', + ' const body = JSON.stringify({', + ' paneKey,', + " tabId: process.env.ORCA_TAB_ID || '',", + " worktreeId: process.env.ORCA_WORKTREE_ID || '',", + ' env: coords.env,', + ' version: coords.version,', + ' payload: { hook_event_name: hookEventName, ...extra },', + ' })', + ' try {', + ' await fetch(url, {', + " method: 'POST',", + ' headers: {', + " 'Content-Type': 'application/json',", + " 'X-Orca-Agent-Hook-Token': coords.token,", + ' },', + ' body,', + ' })', + ' } catch {', + ' // Why: status reporting must never fail the pi run just because Orca', + ' // is unavailable or the loopback request failed (e.g. Orca restart).', + ' }', + '}', + '', + '// Why: pi assistant messages carry content as an array of parts', + "// ({ type: 'text', text } / tool_use / tool_result / reasoning). We only", + "// surface the concatenated text parts as the visible 'last assistant", + "// message' for the dashboard preview — tool_use / reasoning would be", + '// noise (the dashboard already shows the active tool name + input).', + 'function extractAssistantText(message: unknown): string {', + " if (!message || typeof message !== 'object') return ''", + ' const content = (message as { content?: unknown }).content', + " if (typeof content === 'string') return content", + " if (!Array.isArray(content)) return ''", + " let out = ''", + ' for (const part of content) {', + " if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {", + ' const text = (part as { text?: unknown }).text', + " if (typeof text === 'string') out += text", + ' }', + ' }', + ' return out', + '}', + '', + "// Why: pi's tool_call event input shape is tool-specific (event.input is", + '// the raw args object). The agent-hooks server already runs', + '// deriveToolInputPreview(toolName, input) to render a friendly preview', + "// for known tool names ('bash' → command, 'read'/'write'/'edit' → path,", + '// etc.), so we forward the raw object verbatim under the same field', + '// names Claude uses (tool_name / tool_input) and let the server pick the', + '// preview. Keeps tool-name knowledge centralized on the receiver side.', + 'export default function (pi: ExtensionAPI): void {', + " pi.on('before_agent_start', async (event) => {", + " await post('before_agent_start', { prompt: event.prompt ?? '' })", + ' })', + '', + " pi.on('agent_start', async () => {", + " await post('agent_start')", + ' })', + '', + " pi.on('tool_execution_start', async (event) => {", + " await post('tool_execution_start', {", + ' tool_name: event.toolName,', + ' tool_input: event.args,', + ' })', + ' })', + '', + " pi.on('tool_call', async (event) => {", + " await post('tool_call', {", + ' tool_name: event.toolName,', + ' tool_input: event.input,', + ' })', + ' })', + '', + " pi.on('tool_execution_end', async (event) => {", + " await post('tool_execution_end', {", + ' tool_name: event.toolName,', + ' })', + ' })', + '', + " // Why: capture the assistant's final text on each completed message", + ' // so the dashboard preview reflects the most recent reply even before', + ' // agent_end fires. message_end is the right hook because pi guarantees', + ' // it fires after the message is finalized (post-streaming).', + " pi.on('message_end', async (event) => {", + " if (event.message?.role !== 'assistant') return", + ' const text = extractAssistantText(event.message)', + ' if (!text) return', + " await post('message_end', { role: 'assistant', text })", + ' })', + '', + " pi.on('agent_end', async () => {", + " await post('agent_end')", + ' })', + '', + " pi.on('session_shutdown', async () => {", + " await post('session_shutdown')", + ' })', + '}', + '' + ].join('\n') +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index e8458c77f9d..92ae6da24d6 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -71,7 +71,12 @@ describe('PiTitlebarExtensionService', () => { expect(env.PI_CODING_AGENT_DIR).toBe(join(userDataDir, 'pi-agent-overlays', 'pty-1')) // Orca's titlebar extension is added alongside user extensions, not replacing them. const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort() - expect(overlayExtensions).toEqual(['orca-titlebar-spinner.ts', 'user-ext']) + expect(overlayExtensions).toEqual([ + 'orca-agent-status.ts', + 'orca-prefill.ts', + 'orca-titlebar-spinner.ts', + 'user-ext' + ]) // User's top-level resources are reachable via the overlay. expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'skills', 'my-skill', 'SKILL.md'))).toBe(true) expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(true) diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 407adbc18ce..32fb00fb27b 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,24 +1,62 @@ -import { - cpSync, - existsSync, - lstatSync, - linkSync, - mkdirSync, - readdirSync, - rmdirSync, - symlinkSync, - unlinkSync, - writeFileSync -} from 'fs' +import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'fs' import { homedir } from 'os' -import { basename, join, relative, resolve, sep } from 'path' +import { basename, join } from 'path' import { app } from 'electron' +import { + ORCA_PI_AGENT_STATUS_EXTENSION_FILE, + getPiAgentStatusExtensionSource +} from './agent-status-extension-source' +import { + isSafeDescendCandidate as sharedIsSafeDescendCandidate, + mirrorEntry, + safeRemoveOverlay +} from '../pty/overlay-mirror' + +// Why: the Pi test suite imports `isSafeDescendCandidate` from this module's +// public surface to lock in the Windows-junction ordering invariant against +// future refactors. Re-export the shared implementation so the test contract +// keeps holding after the helper moved to src/main/pty/overlay-mirror.ts. +export const isSafeDescendCandidate = sharedIsSafeDescendCandidate const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts' +const ORCA_PI_PREFILL_EXTENSION_FILE = 'orca-prefill.ts' const PI_AGENT_DIR_NAME = '.pi' const PI_AGENT_SUBDIR = 'agent' const PI_OVERLAY_DIR_NAME = 'pi-agent-overlays' +// Why: env-var name read by the prefill extension. Mirrors Claude's +// `--prefill ` semantics for pi — the editor mounts with the text +// already in its input box but unsubmitted, so the user can review/edit +// before sending. Used by the new-workspace draft-launch flow to drop a +// linked GitHub/Linear issue URL into pi without racing pi's lengthy +// startup output (banner + skills + extensions) against the bracketed- +// paste readiness detector. +export const ORCA_PI_PREFILL_ENV_VAR = 'ORCA_PI_PREFILL' + +// Why: pi exposes `pi.ui.setEditorText(text)` from inside an extension's +// session_start handler — that's the equivalent of Claude's `--prefill`. +// We can't use a CLI flag (pi has none) and bracketed-paste-after-ready +// races against pi's startup output, so we ship a tiny extension that +// reads ORCA_PI_PREFILL on session_start and types it into the editor. +// The env var is consumed (deleted from process.env) so /new in the same +// session doesn't re-prefill. +function getPiPrefillExtensionSource(): string { + return [ + 'export default function (pi) {', + " pi.on('session_start', async (event, ctx) => {", + " if (event.reason !== 'startup') return", + ` const prefill = process.env.${ORCA_PI_PREFILL_ENV_VAR}`, + ' if (!prefill) return', + ` delete process.env.${ORCA_PI_PREFILL_ENV_VAR}`, + ' try {', + ' ctx.ui.setEditorText(prefill)', + ' } catch {}', + ' })', + '}', + '' + ].join('\n') +} + function getPiTitlebarExtensionSource(): string { return [ 'const BRAILLE_FRAMES = [', @@ -85,49 +123,6 @@ function getDefaultPiAgentDir(): string { return join(homedir(), PI_AGENT_DIR_NAME, PI_AGENT_SUBDIR) } -function mirrorEntry(sourcePath: string, targetPath: string): void { - // Why: lstatSync (not statSync) so that if the user's Pi dir contains its - // OWN symlinks (e.g. skills symlinked from ~/.agents/skills), we mirror the - // link itself rather than resolving it to a type and then creating a junction - // at an unrelated path. isSymbolicLink() MUST be checked before isDirectory() - // on Windows because directory junctions/reparse points report both true. - const sourceStats = lstatSync(sourcePath) - const isSymlink = sourceStats.isSymbolicLink() - const isDirectoryLike = !isSymlink && sourceStats.isDirectory() - - if (process.platform === 'win32') { - if (isDirectoryLike) { - symlinkSync(sourcePath, targetPath, 'junction') - return - } - - try { - linkSync(sourcePath, targetPath) - return - } catch { - cpSync(sourcePath, targetPath) - return - } - } - - symlinkSync(sourcePath, targetPath, isDirectoryLike ? 'dir' : 'file') -} - -// Exported for tests. A "descend candidate" is an entry whose children we -// should recurse into when tearing down the overlay. Anything that is a -// symlink (including a Windows directory junction) must NOT be a candidate -// even if it also reports isDirectory() — following it would walk into the -// link target and delete user data, which is the bug in #1083. -export function isSafeDescendCandidate(stats: { - isSymbolicLink(): boolean - isDirectory(): boolean -}): boolean { - if (stats.isSymbolicLink()) { - return false - } - return stats.isDirectory() -} - export class PiTitlebarExtensionService { private getOverlayRoot(): string { return join(app.getPath('userData'), PI_OVERLAY_DIR_NAME) @@ -137,85 +132,11 @@ export class PiTitlebarExtensionService { return join(this.getOverlayRoot(), ptyId) } - // Why: the overlay tree contains symlinks/junctions that point back into the - // user's real Pi state (~/.pi/agent or $PI_CODING_AGENT_DIR). fs.rmSync with - // { recursive: true } has repeatedly regressed on Windows when walking - // NTFS junctions — it can follow them and delete the *target*, destroying - // the user's skills, extensions, sessions, and auth.json. See issue #1083. - // - // Never descend into a symlink/junction here: for any non-real-directory - // entry we unlink the link itself; only entries that are truly directories - // on disk (our own extensions/ dir and the overlay root) are recursed into. - // We also refuse to operate on any path outside the overlay root as a - // last-line guard against PI_OVERLAY_DIR_NAME ever being mis-resolved. + // Why: overlay teardown must use the shared safeRemoveOverlay so the + // Windows-junction guard from issue #1083 stays in lock-step across all + // overlay consumers (Pi here, OpenCode in src/main/opencode/hook-service.ts). private safeRemoveOverlay(overlayDir: string): void { - const overlayRoot = this.getOverlayRoot() - const resolvedRoot = resolve(overlayRoot) - const resolvedTarget = resolve(overlayDir) - const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel.startsWith('..') || rel.includes(`..${sep}`)) { - // Target is not strictly inside the overlay root — refuse to touch it. - // Log so a misconfigured caller does not silently leak overlays forever - // with no signal that this guard is firing. - console.warn( - `[pi-titlebar] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` - ) - return - } - this.safeRemoveTree(resolvedTarget) - } - - private safeRemoveTree(path: string): void { - let stat - try { - stat = lstatSync(path) - } catch { - return - } - - // Any symlink or Windows junction is unlinked in place, NEVER descended. - // statSync would follow the link and report the target's stats, which is - // exactly the bug we are guarding against, so the check uses lstat. - // - // On Windows, lstat on a directory junction can report BOTH - // isSymbolicLink() === true AND isDirectory() === true, so we MUST check - // isSymbolicLink first — otherwise a junction enters the recursive branch - // and readdirSync enumerates the link's target, the exact bug in #1083. - if (!isSafeDescendCandidate(stat)) { - try { - unlinkSync(path) - } catch { - // Best-effort: antivirus/indexers can hold handles briefly on Windows. - // A leftover link is harmless; the next spawn rebuilds the overlay. - } - return - } - - let entries - try { - entries = readdirSync(path, { withFileTypes: true }) - } catch { - return - } - - for (const entry of entries) { - const child = join(path, entry.name) - if (isSafeDescendCandidate(entry)) { - this.safeRemoveTree(child) - continue - } - try { - unlinkSync(child) - } catch { - // best-effort, see above - } - } - - try { - rmdirSync(path) - } catch { - // Directory may be non-empty if an unlink above failed; harmless. - } + safeRemoveOverlay(overlayDir, this.getOverlayRoot()) } private mirrorAgentDir(sourceAgentDir: string, overlayDir: string): void { @@ -272,6 +193,18 @@ export class PiTitlebarExtensionService { // instead of replacing that directory, otherwise Orca terminals would // silently disable the user's Pi customization inside Orca only. writeFileSync(join(extensionsDir, ORCA_PI_EXTENSION_FILE), getPiTitlebarExtensionSource()) + writeFileSync( + join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE), + getPiPrefillExtensionSource() + ) + // Why: bundled status extension that bridges pi's in-process event API + // to the unified /hook/pi endpoint. Without this, pi panes would have + // no entry in agentStatusByPaneKey and the dashboard would fall back + // to terminal-title heuristics like any uninstrumented CLI. + writeFileSync( + join(extensionsDir, ORCA_PI_AGENT_STATUS_EXTENSION_FILE), + getPiAgentStatusExtensionSource() + ) } catch { // Why: overlay creation is best-effort — permission errors (EPERM/EACCES) // on Windows can occur when the userData directory is restricted or when diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 25cdabc469c..fe660ed6dfa 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -211,7 +211,7 @@ describe('LocalPtyProvider', () => { // socket-close → SIGHUP-to-recycled-pid race (see docs/fix-pty-fd-leak.md). const killSpy = mockProc.kill const { id } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id, true) + await provider.shutdown(id, { immediate: true }) expect(killSpy).toHaveBeenCalled() }) @@ -219,12 +219,12 @@ describe('LocalPtyProvider', () => { const onExit = vi.fn() provider.configure({ onExit }) const { id } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id, true) + await provider.shutdown(id, { immediate: true }) expect(onExit).toHaveBeenCalledWith(id, -1) }) it('is a no-op for unknown PTY ids', async () => { - await provider.shutdown('nonexistent', true) + await provider.shutdown('nonexistent', { immediate: true }) expect(mockProc.kill).not.toHaveBeenCalled() }) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index e156f429c92..e14efc54912 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -155,6 +155,9 @@ export class LocalPtyProvider implements IPtyProvider { let effectiveCwd: string let validationCwd: string let shellReadyLaunch: ReturnType | null = null + let getFallbackShellReadyConfig: + | ((shell: string) => ReturnType) + | undefined if (wslInfo) { const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''") shellPath = 'wsl.exe' @@ -249,9 +252,20 @@ export class LocalPtyProvider implements IPtyProvider { const finalEnv = this.opts.buildSpawnEnv ? this.opts.buildSpawnEnv(id, spawnEnv) : spawnEnv if (!wslInfo && process.platform !== 'win32') { + // Why: any Orca-injected overlay env that user rc files can clobber + // needs the wrapper so the post-rc restore line runs. + const needsNoMarkerWrapper = + finalEnv.ORCA_ATTRIBUTION_SHIM_DIR || + finalEnv.ORCA_OPENCODE_CONFIG_DIR || + finalEnv.ORCA_PI_CODING_AGENT_DIR + getFallbackShellReadyConfig = args.command + ? (shell) => getShellReadyLaunchConfig(shell) + : needsNoMarkerWrapper + ? (shell) => getAttributionShellLaunchConfig(shell) + : undefined const shellLaunch = args.command ? getShellReadyLaunchConfig(shellPath) - : finalEnv.ORCA_ATTRIBUTION_SHIM_DIR + : needsNoMarkerWrapper ? getAttributionShellLaunchConfig(shellPath) : null if (shellLaunch) { @@ -283,7 +297,7 @@ export class LocalPtyProvider implements IPtyProvider { cwd: effectiveCwd, env: finalEnv, ptySpawn: pty.spawn, - getShellReadyConfig: args.command ? (shell) => getShellReadyLaunchConfig(shell) : undefined, + getShellReadyConfig: getFallbackShellReadyConfig, // Why: if zsh failed and bash took over, HISTFILE still points to // zsh_history. Update it *before* spawn so the child inherits the // correct filename (see design doc §8). @@ -415,7 +429,7 @@ export class LocalPtyProvider implements IPtyProvider { ptyProcesses.get(id)?.resize(cols, rows) } - async shutdown(id: string, _immediate: boolean): Promise { + async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise { const proc = ptyProcesses.get(id) if (!proc) { return diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index f8084802700..b9df5792b8f 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: shell-ready wrapper coverage keeps zsh, + bash, marker scanning, and env restoration cases in one suite so the + generated wrapper contract is reviewed as a unit. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'os' import { join } from 'path' @@ -128,13 +131,21 @@ const describePosix = process.platform === 'win32' ? describe.skip : describe describePosix('local PTY shell-ready launch config', () => { let userDataPath: string + let previousOrcaOrigZdotdir: string | undefined beforeEach(() => { + previousOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR + delete process.env.ORCA_ORIG_ZDOTDIR userDataPath = mkdtempSync(join(tmpdir(), 'local-pty-shell-ready-test-')) getUserDataPathMock.mockReturnValue(userDataPath) }) afterEach(() => { + if (previousOrcaOrigZdotdir === undefined) { + delete process.env.ORCA_ORIG_ZDOTDIR + } else { + process.env.ORCA_ORIG_ZDOTDIR = previousOrcaOrigZdotdir + } rmSync(userDataPath, { recursive: true, force: true }) vi.restoreAllMocks() }) @@ -234,6 +245,27 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) + it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => { + const { getBashShellReadyRcfileContent, getShellReadyLaunchConfig } = + await importFreshLocalPtyShellReady() + + getShellReadyLaunchConfig('/bin/zsh') + + const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8') + const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + const bashRc = getBashShellReadyRcfileContent() + const restoreLine = + '[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"' + const piRestoreLine = + '[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"' + expect(zshrc).toContain(restoreLine) + expect(zlogin).toContain(restoreLine) + expect(bashRc).toContain(restoreLine) + expect(zshrc).toContain(piRestoreLine) + expect(zlogin).toContain(piRestoreLine) + expect(bashRc).toContain(piRestoreLine) + }) + it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => { const previousZdotdir = process.env.ZDOTDIR process.env.ZDOTDIR = '/Users/alice/.config/zsh' diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 7ad3c09b902..85ce95c284c 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: this module owns both shell wrapper file + generation and the matching startup-command readiness scanner; splitting + them would make the wrapper/marker contract harder to audit. */ /** * Shell-ready startup command support for local PTYs. * @@ -132,6 +135,11 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path +# Why: user startup files may set the default OpenCode config after Orca's +# spawn env; restore the PTY-scoped overlay before the first prompt. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +# Why: PI_CODING_AGENT_DIR is also a single-root env var users may re-export. +[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" # Why: append the marker through PROMPT_COMMAND so it fires after the login # startup files have rebuilt the prompt, without re-running user rc files. if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then @@ -192,7 +200,13 @@ __orca_restore_attribution_path() { esac export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } -[[ ! -o login ]] && __orca_restore_attribution_path +if [[ ! -o login ]]; then + __orca_restore_attribution_path + # Why: ~/.zshrc can export the user's default OpenCode config after spawn. + [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" + # Why: PI_CODING_AGENT_DIR must keep the same PTY-scoped overlay after rc files. + [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +fi ` const zshLogin = `# Orca zsh shell-ready wrapper _orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" @@ -210,13 +224,18 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path -# Why: emit OSC 133;A only after the user's startup hooks finish so Orca knows -# the prompt is actually ready for a long startup command paste. +# Why: .zlogin is the final login startup file before the prompt is shown. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +# Why: zsh precmd runs before the prompt is drawn and before zle owns input, +# which can double-echo startup commands. line-init fires when zle is ready. if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "\\033]133;A\\007" } - precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) + autoload -Uz add-zle-hook-widget + zle -N __orca_prompt_mark + add-zle-hook-widget line-init __orca_prompt_mark fi ` const bashRc = getBashShellReadyRcfileContent() diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index fc0cbe21555..0c5235b9264 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,9 +1,29 @@ -import { basename } from 'path' +import { basename, join } from 'path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'fs' import type * as pty from 'node-pty' let didEnsureSpawnHelperExecutable = false +function toUnpackedAsarPath(candidate: string): string { + return candidate + .replace(/app\.asar([/\\])/, 'app.asar.unpacked$1') + .replace(/node_modules\.asar([/\\])/, 'node_modules.asar.unpacked$1') +} + +export function getNodePtySpawnHelperCandidates(): string[] { + const unixTerminalPath = require.resolve('node-pty/lib/unixTerminal.js') + const packageRoot = + basename(unixTerminalPath) === 'unixTerminal.js' + ? unixTerminalPath.replace(/[/\\]lib[/\\]unixTerminal\.js$/, '') + : unixTerminalPath + + return [ + join(packageRoot, 'build', 'Release', 'spawn-helper'), + join(packageRoot, 'build', 'Debug', 'spawn-helper'), + join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper') + ].map(toUnpackedAsarPath) +} + /** * Validate that a shell binary exists and is executable. * Returns an error message string if invalid, null if valid. @@ -37,22 +57,7 @@ export function ensureNodePtySpawnHelperExecutable(): void { didEnsureSpawnHelperExecutable = true try { - const unixTerminalPath = require.resolve('node-pty/lib/unixTerminal.js') - const packageRoot = - basename(unixTerminalPath) === 'unixTerminal.js' - ? unixTerminalPath.replace(/[/\\]lib[/\\]unixTerminal\.js$/, '') - : unixTerminalPath - const candidates = [ - `${packageRoot}/build/Release/spawn-helper`, - `${packageRoot}/build/Debug/spawn-helper`, - `${packageRoot}/prebuilds/${process.platform}-${process.arch}/spawn-helper` - ].map((candidate) => - candidate - .replace('app.asar/', 'app.asar.unpacked/') - .replace('node_modules.asar/', 'node_modules.asar.unpacked/') - ) - - for (const candidate of candidates) { + for (const candidate of getNodePtySpawnHelperCandidates()) { if (!existsSync(candidate)) { continue } diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts new file mode 100644 index 00000000000..1932af4ce5d --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { SshFilesystemProvider } from './ssh-filesystem-provider' + +type MockMultiplexer = { + request: ReturnType + notify: ReturnType + onNotification: ReturnType + onNotificationByMethod: ReturnType + onDispose: ReturnType + dispose: ReturnType + isDisposed: ReturnType + _emitMethod: (method: string, params: Record) => void +} + +function createMockMux(): MockMultiplexer { + const methodHandlers = new Map) => void>>() + return { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + onNotification: vi.fn(), + onNotificationByMethod: vi.fn( + (method: string, handler: (params: Record) => void) => { + let set = methodHandlers.get(method) + if (!set) { + set = new Set() + methodHandlers.set(method, set) + } + set.add(handler) + return () => set!.delete(handler) + } + ), + onDispose: vi.fn(() => () => {}), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false), + _emitMethod: (method, params) => { + const set = methodHandlers.get(method) + if (set) { + for (const handler of Array.from(set)) { + handler(params) + } + } + } + } +} + +describe('SshFilesystemProvider readFile streaming', () => { + let mux: MockMultiplexer + let provider: SshFilesystemProvider + + beforeEach(() => { + mux = createMockMux() + provider = new SshFilesystemProvider('conn-1', mux as never) + }) + + it('streams via fs.readFileStream and reassembles utf-8 text', async () => { + const text = 'hello world' + const totalSize = Buffer.byteLength(text, 'utf-8') + mux.request.mockImplementation(async (method: string) => { + if (method !== 'fs.readFileStream') { + throw new Error(`unexpected method ${method}`) + } + // Why: setImmediate fires after the metadata-resolution .then has set + // streamIdRef, ensuring subscribed handlers see a matching streamId. + setImmediate(() => { + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 0, + data: Buffer.from(text, 'utf-8').toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 1 }) + }) + return { + streamId: 1, + totalSize, + isBinary: false, + chunkEncoding: 'base64', + resultEncoding: 'utf-8' + } + }) + + const result = await provider.readFile('/home/user/file.txt') + expect(mux.request).toHaveBeenCalledWith('fs.readFileStream', { + filePath: '/home/user/file.txt' + }) + expect(result).toEqual({ content: text, isBinary: false }) + }) + + it('falls back to legacy fs.readFile on -32601 method-not-found', async () => { + const legacyResult = { content: 'legacy', isBinary: false } + mux.request.mockImplementation(async (method: string) => { + if (method === 'fs.readFileStream') { + const err = new Error('Method not found') as Error & { code: number } + err.code = -32601 + throw err + } + if (method === 'fs.readFile') { + return legacyResult + } + throw new Error(`unexpected method ${method}`) + }) + + const result = await provider.readFile('/home/user/file.txt') + expect(mux.request).toHaveBeenCalledWith('fs.readFile', { filePath: '/home/user/file.txt' }) + expect(result).toEqual(legacyResult) + }) + + it('rejects when chunk arrives out of order', async () => { + const totalSize = 256 * 1024 * 2 + mux.request.mockImplementation(async () => { + setImmediate(() => { + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 1, + data: Buffer.alloc(256 * 1024).toString('base64') + }) + }) + return { + streamId: 1, + totalSize, + isBinary: true, + chunkEncoding: 'base64', + resultEncoding: 'base64' + } + }) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/out-of-order/i) + }) + + it('rejects when totalSize exceeds client cap without allocating', async () => { + mux.request.mockResolvedValue({ + streamId: 1, + totalSize: 51 * 1024 * 1024, + isBinary: true, + chunkEncoding: 'base64', + resultEncoding: 'base64' + }) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/exceeds client cap/i) + expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 1 }) + }) + + it('rejects on fs.streamError notification', async () => { + const totalSize = 1024 + mux.request.mockImplementation(async () => { + setImmediate(() => { + mux._emitMethod('fs.streamError', { + streamId: 7, + code: 'ENOENT', + message: 'gone' + }) + }) + return { + streamId: 7, + totalSize, + isBinary: false, + chunkEncoding: 'base64', + resultEncoding: 'utf-8' + } + }) + await expect(provider.readFile('/home/x.txt')).rejects.toThrow(/gone/) + }) + + it('rejects on chunk count mismatch at streamEnd', async () => { + const totalSize = 256 * 1024 * 3 + mux.request.mockImplementation(async () => { + setImmediate(() => { + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 0, + data: Buffer.alloc(256 * 1024).toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 1 }) + }) + return { + streamId: 1, + totalSize, + isBinary: true, + chunkEncoding: 'base64', + resultEncoding: 'base64' + } + }) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/count mismatch/i) + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index e83ac485e16..dca8cf06bc9 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -5,17 +5,43 @@ type MockMultiplexer = { request: ReturnType notify: ReturnType onNotification: ReturnType + onNotificationByMethod: ReturnType + onDispose: ReturnType dispose: ReturnType isDisposed: ReturnType + _methodHandlers: Map) => void>> + _emitMethod: (method: string, params: Record) => void } function createMockMux(): MockMultiplexer { + const methodHandlers = new Map) => void>>() return { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn(), onNotification: vi.fn(), + onNotificationByMethod: vi.fn( + (method: string, handler: (params: Record) => void) => { + let set = methodHandlers.get(method) + if (!set) { + set = new Set() + methodHandlers.set(method, set) + } + set.add(handler) + return () => set!.delete(handler) + } + ), + onDispose: vi.fn(() => () => {}), dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) + isDisposed: vi.fn().mockReturnValue(false), + _methodHandlers: methodHandlers, + _emitMethod: (method, params) => { + const set = methodHandlers.get(method) + if (set) { + for (const handler of Array.from(set)) { + handler(params) + } + } + } } } @@ -47,13 +73,10 @@ describe('SshFilesystemProvider', () => { }) describe('readFile', () => { - it('sends fs.readFile request', async () => { - const fileResult = { content: 'hello world', isBinary: false } - mux.request.mockResolvedValue(fileResult) - - const result = await provider.readFile('/home/user/file.txt') - expect(mux.request).toHaveBeenCalledWith('fs.readFile', { filePath: '/home/user/file.txt' }) - expect(result).toEqual(fileResult) + it('short-circuits on empty:true metadata without subscribing to chunks', async () => { + mux.request.mockResolvedValue({ totalSize: 0, isBinary: false, empty: true }) + const result = await provider.readFile('/home/user/empty.txt') + expect(result).toEqual({ content: '', isBinary: false }) }) }) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 903dc71e7b0..d0c545d6881 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,4 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' import type { IFilesystemProvider, FileStat, FileReadResult } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' @@ -13,6 +14,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { // multiplexer. Without this, notification callbacks keep firing after // the provider is torn down on disconnect, routing events to stale state. private unsubscribeNotifications: (() => void) | null = null + // Why: relays from a previous build may not implement fs.readFileStream. + // We log the fallback once per session at warn level so users on stale + // relays get diagnosed quickly without per-read log spam. + private loggedStreamFallback = false constructor(connectionId: string, mux: SshChannelMultiplexer) { this.connectionId = connectionId @@ -48,7 +53,25 @@ export class SshFilesystemProvider implements IFilesystemProvider { } async readFile(filePath: string): Promise { - return (await this.mux.request('fs.readFile', { filePath })) as FileReadResult + // Why: streaming is the default path so previews above the legacy single- + // frame budget (~12 MB after base64) don't hit MAX_MESSAGE_SIZE. Old relays + // that don't implement fs.readFileStream surface as MethodNotFound; we fall + // back to the legacy single-shot fs.readFile (which retains the old 10 MB + // cap on those hosts). + try { + return await readFileViaStream(this.mux, filePath) + } catch (err) { + if (isMethodNotFoundError(err)) { + if (!this.loggedStreamFallback) { + this.loggedStreamFallback = true + console.warn( + '[ssh-fs] Relay does not implement fs.readFileStream; falling back to fs.readFile (10 MB cap)' + ) + } + return (await this.mux.request('fs.readFile', { filePath })) as FileReadResult + } + throw err + } } async writeFile(filePath: string, content: string): Promise { diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 3ccfbb11746..0a0c759b515 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -41,6 +41,19 @@ describe('SshGitProvider', () => { expect(result).toEqual(statusResult) }) + it('commit sends git.commit request', async () => { + const commitResult = { success: true } + mux.request.mockResolvedValue(commitResult) + + const result = await provider.commit('/home/user/repo', 'feat: add source control commit') + + expect(mux.request).toHaveBeenCalledWith('git.commit', { + worktreePath: '/home/user/repo', + message: 'feat: add source control commit' + }) + expect(result).toEqual(commitResult) + }) + it('getDiff sends git.diff request', async () => { const diffResult = { kind: 'text', originalContent: '', modifiedContent: 'hello' } mux.request.mockResolvedValue(diffResult) @@ -115,6 +128,39 @@ describe('SshGitProvider', () => { expect(result).toEqual(compareResult) }) + it('getUpstreamStatus sends git.upstreamStatus request', async () => { + const upstreamResult = { hasUpstream: true, upstreamName: 'origin/main', ahead: 1, behind: 0 } + mux.request.mockResolvedValue(upstreamResult) + + const result = await provider.getUpstreamStatus('/home/user/repo') + expect(mux.request).toHaveBeenCalledWith('git.upstreamStatus', { + worktreePath: '/home/user/repo' + }) + expect(result).toEqual(upstreamResult) + }) + + it('pushBranch sends git.push request and forwards publish mode', async () => { + await provider.pushBranch('/home/user/repo', true) + expect(mux.request).toHaveBeenCalledWith('git.push', { + worktreePath: '/home/user/repo', + publish: true + }) + }) + + it('pullBranch sends git.pull request', async () => { + await provider.pullBranch('/home/user/repo') + expect(mux.request).toHaveBeenCalledWith('git.pull', { + worktreePath: '/home/user/repo' + }) + }) + + it('fetchRemote sends git.fetch request', async () => { + await provider.fetchRemote('/home/user/repo') + expect(mux.request).toHaveBeenCalledWith('git.fetch', { + worktreePath: '/home/user/repo' + }) + }) + it('getBranchDiff sends git.branchDiff request', async () => { const diffs = [{ kind: 'text', originalContent: '', modifiedContent: 'new' }] mux.request.mockResolvedValue(diffs) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 75ced3966cf..8af00a6b408 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -6,6 +6,7 @@ import type { GitDiffResult, GitBranchCompareResult, GitConflictOperation, + GitUpstreamStatus, GitWorktreeInfo } from '../../shared/types' @@ -26,6 +27,16 @@ export class SshGitProvider implements IGitProvider { return (await this.mux.request('git.status', { worktreePath })) as GitStatusResult } + async commit( + worktreePath: string, + message: string + ): Promise<{ success: boolean; error?: string }> { + return (await this.mux.request('git.commit', { + worktreePath, + message + })) as { success: boolean; error?: string } + } + async getDiff( worktreePath: string, filePath: string, @@ -73,6 +84,24 @@ export class SshGitProvider implements IGitProvider { })) as GitBranchCompareResult } + async getUpstreamStatus(worktreePath: string): Promise { + return (await this.mux.request('git.upstreamStatus', { + worktreePath + })) as GitUpstreamStatus + } + + async pushBranch(worktreePath: string, publish = false): Promise { + await this.mux.request('git.push', { worktreePath, publish }) + } + + async pullBranch(worktreePath: string): Promise { + await this.mux.request('git.pull', { worktreePath }) + } + + async fetchRemote(worktreePath: string): Promise { + await this.mux.request('git.fetch', { worktreePath }) + } + async getBranchDiff( worktreePath: string, baseRef: string, @@ -95,7 +124,7 @@ export class SshGitProvider implements IGitProvider { repoPath: string, branchName: string, targetDir: string, - options?: { base?: string; track?: boolean } + options?: { base?: string } ): Promise { await this.mux.request('git.addWorktree', { repoPath, diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index fc4c1ca9170..e705ef8f552 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -122,8 +122,21 @@ describe('SshPtyProvider', () => { }) it('shutdown sends pty.shutdown request', async () => { - await provider.shutdown('pty-1', true) - expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { id: 'pty-1', immediate: true }) + await provider.shutdown('pty-1', { immediate: true }) + expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { + id: 'pty-1', + immediate: true, + keepHistory: false + }) + }) + + it('shutdown forwards keepHistory: true over the relay', async () => { + await provider.shutdown('pty-1', { immediate: true, keepHistory: true }) + expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { + id: 'pty-1', + immediate: true, + keepHistory: true + }) }) it('sendSignal sends pty.sendSignal request', async () => { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 8d251ca3bd4..652c28922d2 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -121,8 +121,12 @@ export class SshPtyProvider implements IPtyProvider { this.mux.notify('pty.resize', { id, cols, rows }) } - async shutdown(id: string, immediate: boolean): Promise { - await this.mux.request('pty.shutdown', { id, immediate }) + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + await this.mux.request('pty.shutdown', { + id, + immediate: opts.immediate ?? false, + keepHistory: opts.keepHistory ?? false + }) } async sendSignal(id: string, signal: string): Promise { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 6c31de31b45..f79a0723542 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -5,6 +5,7 @@ import type { GitDiffResult, GitBranchCompareResult, GitConflictOperation, + GitUpstreamStatus, GitWorktreeInfo, SearchOptions, SearchResult @@ -79,7 +80,7 @@ export type IPtyProvider = { attach(id: string): Promise write(id: string, data: string): void resize(id: string, cols: number, rows: number): void - shutdown(id: string, immediate: boolean): Promise + shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise sendSignal(id: string, signal: string): Promise getCwd(id: string): Promise getInitialCwd(id: string): Promise @@ -132,6 +133,7 @@ export type IFilesystemProvider = { export type IGitProvider = { getStatus(worktreePath: string): Promise + commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> getDiff( worktreePath: string, filePath: string, @@ -145,6 +147,10 @@ export type IGitProvider = { discardChanges(worktreePath: string, filePath: string): Promise detectConflictOperation(worktreePath: string): Promise getBranchCompare(worktreePath: string, baseRef: string): Promise + getUpstreamStatus(worktreePath: string): Promise + pushBranch(worktreePath: string, publish?: boolean): Promise + pullBranch(worktreePath: string): Promise + fetchRemote(worktreePath: string): Promise getBranchDiff( worktreePath: string, baseRef: string, @@ -155,7 +161,7 @@ export type IGitProvider = { repoPath: string, branchName: string, targetDir: string, - options?: { base?: string; track?: boolean } + options?: { base?: string } ): Promise removeWorktree(worktreePath: string, force?: boolean): Promise isGitRepo(path: string): boolean diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index b83c0c0579e..9836a76367a 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -19,8 +19,23 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.shellArgs[1]).toBe('-Command') // The actual command must dot-source $PROFILE before setting encodings, // otherwise oh-my-posh / starship / PSReadLine never load. - expect(result.shellArgs[2]).toContain('. $PROFILE') - expect(result.shellArgs[2]).toContain('UTF8') + const command = result.shellArgs[2] ?? '' + const profileIndex = command.indexOf('. $PROFILE') + const opencodeRestoreIndex = command.indexOf( + '$env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR' + ) + const piRestoreIndex = command.indexOf( + '$env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR' + ) + const outputEncodingIndex = command.indexOf('[Console]::OutputEncoding') + const inputEncodingIndex = command.indexOf('[Console]::InputEncoding') + + expect(profileIndex).toBeGreaterThanOrEqual(0) + expect(opencodeRestoreIndex).toBeGreaterThan(profileIndex) + expect(piRestoreIndex).toBeGreaterThan(profileIndex) + expect(outputEncodingIndex).toBeGreaterThan(opencodeRestoreIndex) + expect(outputEncodingIndex).toBeGreaterThan(piRestoreIndex) + expect(inputEncodingIndex).toBeGreaterThan(outputEncodingIndex) }) it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => { diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 556dae38979..e13e2895f31 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -45,12 +45,17 @@ export function resolveWindowsShellLaunchArgs( } if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') { + // Why: PowerShell profiles run after the spawn env is set and may re-export + // user defaults; restore Orca's PTY-scoped overlays after the profile. + const command = [ + 'try { . $PROFILE } catch {}', + 'if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR }', + 'if ($env:ORCA_PI_CODING_AGENT_DIR) { $env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR }', + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + '[Console]::InputEncoding = [System.Text.Encoding]::UTF8' + ].join('; ') return { - shellArgs: [ - '-NoExit', - '-Command', - 'try { . $PROFILE } catch {}; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8' - ], + shellArgs: ['-NoExit', '-Command', command], effectiveCwd: cwd, validationCwd: cwd } diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts new file mode 100644 index 00000000000..d529ac139b2 --- /dev/null +++ b/src/main/pty/overlay-mirror.ts @@ -0,0 +1,128 @@ +// Why: Pi (PI_CODING_AGENT_DIR) and OpenCode (OPENCODE_CONFIG_DIR) both inject +// Orca-owned files into per-PTY overlay directories that mirror a user-owned +// source dir via symlinks/junctions. The safety guarantees here — never +// descend into a symlink/junction during teardown, refuse to operate outside +// the overlay root, lstat-not-stat to avoid following links — are the result +// of debugging issue #1083 (Windows directory junctions causing fs.rmSync to +// delete the user's real Pi state). Shared in one module so a new overlay +// consumer cannot accidentally diverge from the audited cleanup behavior. + +import { cpSync, linkSync, lstatSync, readdirSync, rmdirSync, symlinkSync, unlinkSync } from 'fs' +import { join, relative, resolve, sep } from 'path' + +export function mirrorEntry(sourcePath: string, targetPath: string): void { + // Why: lstatSync (not statSync) so that if the user's source dir contains + // its OWN symlinks (e.g. skills symlinked from ~/.agents/skills), we mirror + // the link itself rather than resolving it to a type and then creating a + // junction at an unrelated path. isSymbolicLink() MUST be checked before + // isDirectory() on Windows because directory junctions/reparse points + // report both true. + const sourceStats = lstatSync(sourcePath) + const isSymlink = sourceStats.isSymbolicLink() + const isDirectoryLike = !isSymlink && sourceStats.isDirectory() + + if (process.platform === 'win32') { + if (isDirectoryLike) { + symlinkSync(sourcePath, targetPath, 'junction') + return + } + + try { + linkSync(sourcePath, targetPath) + return + } catch { + cpSync(sourcePath, targetPath) + return + } + } + + symlinkSync(sourcePath, targetPath, isDirectoryLike ? 'dir' : 'file') +} + +// Exported for tests. A "descend candidate" is an entry whose children we +// should recurse into when tearing down the overlay. Anything that is a +// symlink (including a Windows directory junction) must NOT be a candidate +// even if it also reports isDirectory() — following it would walk into the +// link target and delete user data, which is the bug in #1083. +export function isSafeDescendCandidate(stats: { + isSymbolicLink(): boolean + isDirectory(): boolean +}): boolean { + if (stats.isSymbolicLink()) { + return false + } + return stats.isDirectory() +} + +// Why: the overlay tree contains symlinks/junctions that point back into the +// user's real state dir. fs.rmSync with { recursive: true } has repeatedly +// regressed on Windows when walking NTFS junctions — it can follow them and +// delete the *target*, destroying the user's data. Never descend into a +// symlink/junction here: for any non-real-directory entry we unlink the link +// itself; only entries that are truly directories on disk are recursed into. +export function safeRemoveTree(path: string): void { + let stat + try { + stat = lstatSync(path) + } catch { + return + } + + // On Windows, lstat on a directory junction can report BOTH + // isSymbolicLink() === true AND isDirectory() === true, so we MUST check + // isSymbolicLink first — otherwise a junction enters the recursive branch + // and readdirSync enumerates the link's target, the exact bug in #1083. + if (!isSafeDescendCandidate(stat)) { + try { + unlinkSync(path) + } catch { + // Best-effort: antivirus/indexers can hold handles briefly on Windows. + // A leftover link is harmless; the next spawn rebuilds the overlay. + } + return + } + + let entries + try { + entries = readdirSync(path, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const child = join(path, entry.name) + if (isSafeDescendCandidate(entry)) { + safeRemoveTree(child) + continue + } + try { + unlinkSync(child) + } catch { + // best-effort, see above + } + } + + try { + rmdirSync(path) + } catch { + // Directory may be non-empty if an unlink above failed; harmless. + } +} + +// Why: last-line guard against an overlay-root constant ever being +// mis-resolved. Any caller that points safeRemoveTree at a path outside its +// designated overlay root is refused so a misconfiguration cannot turn into +// an `rm -rf` of arbitrary user data. Logs (rather than throws) so a buggy +// caller stays visible without crashing the PTY spawn. +export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void { + const resolvedRoot = resolve(overlayRoot) + const resolvedTarget = resolve(overlayDir) + const rel = relative(resolvedRoot, resolvedTarget) + if (rel === '' || rel.startsWith('..') || rel.includes(`..${sep}`)) { + console.warn( + `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` + ) + return + } + safeRemoveTree(resolvedTarget) +} diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts new file mode 100644 index 00000000000..91418fd0809 --- /dev/null +++ b/src/main/pty/shell-startup-env.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, readFileSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + readFileSyncMock: vi.fn() +})) + +vi.mock('fs', () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock +})) + +import { __resetShellStartupEnvCache, readShellStartupEnvVar } from './shell-startup-env' + +describe('readShellStartupEnvVar', () => { + const originalPlatform = process.platform + const originalShell = process.env.SHELL + + beforeEach(() => { + existsSyncMock.mockReset() + readFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + process.env.SHELL = '/bin/zsh' + __resetShellStartupEnvCache() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + if (originalShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = originalShell + } + }) + + function mockStartupFiles(files: Record) { + existsSyncMock.mockImplementation((p: string) => { + const file = p.split('/').pop() ?? '' + return file in files + }) + readFileSyncMock.mockImplementation((p: string) => { + const file = p.split('/').pop() ?? '' + if (file in files) { + return files[file] + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + } + + // Issue #1534 / PR description: GUI-launched Orca does not inherit + // OPENCODE_CONFIG_DIR; the user's .zshrc exports it. The fallback must + // pick up that export so the overlay mirrors the user's real config. + // Scope: this intentionally covers direct static exports; sourced files, + // conditionals, and full shell evaluation remain out of scope. + it('mirrors the user scenario: GUI-launched Orca discovers .zshrc-only export', () => { + mockStartupFiles({ + '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.config/opencode"\n' + }) + + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( + '/home/alice/.config/opencode' + ) + }) + + it('returns undefined when HOME is unset', () => { + const savedHome = process.env.HOME + delete process.env.HOME + try { + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR')).toBeUndefined() + expect(existsSyncMock).not.toHaveBeenCalled() + } finally { + if (savedHome !== undefined) { + process.env.HOME = savedHome + } + } + }) + + it('returns undefined on Windows', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/win\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() + }) + + it('returns undefined when no startup file matches', () => { + mockStartupFiles({ '.zshrc': 'export FOO=bar\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() + }) + + it('returns the LAST assignment when multiple files re-export', () => { + mockStartupFiles({ + '.zshenv': 'export OPENCODE_CONFIG_DIR="/old/zshenv"\n', + '.zprofile': 'export OPENCODE_CONFIG_DIR="/middle/zprofile"\n', + '.zshrc': 'export OPENCODE_CONFIG_DIR="/new/zshrc"\n', + '.zlogin': 'export OPENCODE_CONFIG_DIR="/newest/zlogin"\n' + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/newest/zlogin') + }) + + it('handles double-quoted values', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/quoted/path"\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path') + }) + + it('handles single-quoted values', () => { + mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='/quoted/path'\n" }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path') + }) + + it('handles unquoted values', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/unquoted/path\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/unquoted/path') + }) + + it('expands $HOME in values', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.opencode"\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( + '/home/alice/.opencode' + ) + }) + + it('expands ${HOME} in values', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="${HOME}/.opencode"\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( + '/home/alice/.opencode' + ) + }) + + it('expands leading ~ in values', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=~/.opencode\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( + '/home/alice/.opencode' + ) + }) + + it('ignores bare assignments without the export keyword', () => { + // Why: POSIX `FOO=bar` (no export) creates a shell-local variable that + // is NOT inherited by child processes. The PTY child shell would never + // see this value, so we should not mirror it as a "source" for overlay + // construction. + mockStartupFiles({ '.zshrc': 'OPENCODE_CONFIG_DIR=/no/export\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() + }) + + it('preserves a # inside a double-quoted value', () => { + // Why: shells treat # as a comment delimiter only when it begins a word + // (preceded by whitespace). Inside quotes it's literal. + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/path/with#hash"\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/path/with#hash') + }) + + it('preserves a # inside a single-quoted value', () => { + mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='/path/with#hash'\n" }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/path/with#hash') + }) + + it('strips a trailing # comment from an unquoted value', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/bare/path # trailing comment\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/bare/path') + }) + + it('strips a trailing # comment after a double-quoted value', () => { + mockStartupFiles({ + '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.opencode" # trailing comment\n' + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe( + '/home/alice/.opencode' + ) + }) + + it('strips a trailing # comment after a single-quoted value', () => { + mockStartupFiles({ + '.zshrc': "export OPENCODE_CONFIG_DIR='/literal/path' # trailing comment\n" + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/literal/path') + }) + + it('does not expand $HOME inside single quotes', () => { + // Why: POSIX shells do not perform parameter expansion in single quotes. + mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='$HOME/.opencode'\n" }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('$HOME/.opencode') + }) + + it('does not partially expand $HOMER / $HOMEPATH', () => { + // Why: real shells require a word boundary; $HOMER is the var HOMER, + // not $HOME + R. + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOMER/agent"\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('$HOMER/agent') + }) + + it('only scans zsh startup files when SHELL is zsh', () => { + // Why: a stale .bash_profile on a zsh user must NOT clobber the value + // from .zshrc, since the live shell would never source .bash_profile. + process.env.SHELL = '/bin/zsh' + mockStartupFiles({ + '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', + '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/zshrc') + }) + + it('only scans bash startup files when SHELL is bash', () => { + process.env.SHELL = '/bin/bash' + mockStartupFiles({ + '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', + '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/bash_profile') + }) + + it('defaults to zsh startup files when SHELL is unset', () => { + delete process.env.SHELL + mockStartupFiles({ + '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', + '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/zshrc') + }) + + it('does not scan .bashrc for bash shells', () => { + // Why: Orca launches bash as a login shell and the shell-ready wrappers + // intentionally do NOT source .bashrc, so a value present only in .bashrc + // would never be set in the live Orca bash shell. + process.env.SHELL = '/bin/bash' + mockStartupFiles({ '.bashrc': 'export OPENCODE_CONFIG_DIR=/from/bashrc\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() + }) + + it('does not scan zsh or bash files for an explicit unsupported shell', () => { + mockStartupFiles({ + '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', + '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' + }) + expect( + readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/opt/bin/fish') + ).toBeUndefined() + }) + + it('honors an explicit shell argument over process.env.SHELL', () => { + // Why: callers (pty.ts) may know the per-spawn SHELL from baseEnv that + // differs from the Orca process's own $SHELL. + process.env.SHELL = '/bin/zsh' + mockStartupFiles({ + '.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n', + '.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n' + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/bash')).toBe( + '/from/bash_profile' + ) + }) + + it('memoizes results across calls within the same process', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/cached\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/cached') + + const callsAfterFirst = readFileSyncMock.mock.calls.length + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/cached') + expect(readFileSyncMock.mock.calls.length).toBe(callsAfterFirst) + }) + + it('rejects names with regex metacharacters', () => { + mockStartupFiles({ '.zshrc': 'export FOO=/x\n' }) + expect(readShellStartupEnvVar('FOO.*', '/home/alice')).toBeUndefined() + }) + + it('does not match other variable names', () => { + mockStartupFiles({ + '.zshrc': 'export OPENCODE_CONFIG_DIR_BACKUP=/backup\nexport NOT_OPENCODE_CONFIG_DIR=/x\n' + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() + }) + + it('survives a readFileSync error on one file and continues', () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockImplementation((p: string) => { + if (p.endsWith('.zshenv')) { + throw new Error('EACCES') + } + if (p.endsWith('.zshrc')) { + return 'export OPENCODE_CONFIG_DIR=/found\n' + } + return '' + }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/found') + }) + + it('handles CRLF line endings', () => { + mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/crlf\r\nexport OTHER=x\r\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/crlf') + }) + + it('does not match an OPENCODE_CONFIG_DIR mention in a comment', () => { + mockStartupFiles({ '.zshrc': '# export OPENCODE_CONFIG_DIR=/from-comment\n' }) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() + }) +}) diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts new file mode 100644 index 00000000000..fea07dd0f7f --- /dev/null +++ b/src/main/pty/shell-startup-env.ts @@ -0,0 +1,167 @@ +import { existsSync, readFileSync } from 'fs' +import { basename, join } from 'path' + +// Why: only files the user's actual shell would source. Mixing zsh and bash +// files breaks the "last assignment wins matches the live shell" guarantee — +// a stale .bash_profile on a zsh user would clobber the real .zshrc value. +const ZSH_FILES = ['.zshenv', '.zprofile', '.zshrc', '.zlogin'] +// Why: Orca launches bash as a login shell (see local-pty-shell-ready.ts +// getBashShellReadyRcfileContent and daemon/shell-ready.ts) which sources +// .bash_profile / .bash_login / .profile but intentionally does NOT force +// .bashrc. Scanning .bashrc would mirror values the live Orca bash never sees. +const BASH_LOGIN_FILES = ['.bash_profile', '.bash_login', '.profile'] + +function shellStartupFiles(shell: string | undefined): readonly string[] { + if (!shell) { + // Why: Orca's POSIX default shell is /bin/zsh when $SHELL is unset. + return ZSH_FILES + } + + const name = basename(shell).toLowerCase() + if (name === 'zsh') { + return ZSH_FILES + } + if (name === 'bash') { + return BASH_LOGIN_FILES + } + // Why: unsupported explicit shells (fish, nushell, custom wrappers) do not + // use Orca's zsh/bash shell-ready startup files, so scanning those files + // would mirror values the live PTY shell never sees. + return [] +} + +function unquoteShellValue(value: string): { text: string; quoted: '"' | "'" | null } { + const trimmed = value.trim() + if (trimmed.length >= 2) { + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return { text: trimmed.slice(1, -1), quoted: '"' } + } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + return { text: trimmed.slice(1, -1), quoted: "'" } + } + } + return { text: trimmed, quoted: null } +} + +function stripTrailingComment(value: string): string { + // Why: shells only treat `#` as a comment delimiter when it begins a word + // (unquoted, preceded by whitespace). Walk the string so `#` inside quotes + // and `path/with#hash` (no preceding whitespace) are preserved literally. + let inSingle = false + let inDouble = false + for (let i = 0; i < value.length; i++) { + const ch = value[i] + if (ch === "'" && !inDouble) { + inSingle = !inSingle + } else if (ch === '"' && !inSingle) { + inDouble = !inDouble + } else if (ch === '#' && !inSingle && !inDouble) { + const prev = value[i - 1] + if (prev === undefined || prev === ' ' || prev === '\t') { + return value.slice(0, i).trimEnd() + } + } + } + return value +} + +function expandHome(value: string, home: string): string { + // Why: word boundary on $HOME so $HOMER / $HOMEPATH / $HOME_DIR are NOT + // partially expanded into a path that doesn't match the live shell. + return value + .replace(/^~(?=$|\/)/, home) + .replace(/\$\{HOME\}/g, home) + .replace(/\$HOME(?![A-Za-z0-9_])/g, home) +} + +const cache = new Map() + +/** + * Best-effort static read of a single env-var assignment from the user's + * POSIX shell startup files. + * + * Why: GUI-launched Orca does not inherit interactive shell exports, but the + * PTY's startup file will later re-export them and override our overlay. By + * peeking at the assignment up-front we can preserve the user's source value + * before installing the overlay. + * + * Limits (callers should treat the result as a hint, not authoritative): + * - Conditionals (`[[ ... ]] && export FOO=...`), sourced files, and + * `$VAR` substitution beyond `$HOME` / `${HOME}` / `~` are not evaluated. + * - Bare assignments (no `export` keyword) are ignored because POSIX shells + * do not export them to child processes. + * - Files are scanned in shell evaluation order for the user's $SHELL family + * only (zsh OR bash, not both); unsupported explicit shells scan nothing. + * LAST matching assignment wins. + * - Windows is unsupported (PowerShell profile parsing is out of scope). + * + * Results are memoized per (name, home, shell) for the process lifetime — + * shell startup files do not change mid-session in any practical scenario, + * and PTY spawn is on the hot path. + */ +export function readShellStartupEnvVar( + name: string, + home = process.env.HOME, + shell = process.env.SHELL +): string | undefined { + if (!home || process.platform === 'win32') { + return undefined + } + // Why: the regex above is fixed; rejecting unsafe names is cheap defense + // for the day a future caller passes something with regex metacharacters. + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + return undefined + } + + const cacheKey = `${name}\0${home}\0${shell ?? ''}` + if (cache.has(cacheKey)) { + return cache.get(cacheKey) + } + + const assignment = new RegExp(`^export\\s+${name}=(.+)$`) + let lastMatch: string | undefined + + for (const file of shellStartupFiles(shell)) { + const path = join(home, file) + if (!existsSync(path)) { + continue + } + + let content + try { + content = readFileSync(path, 'utf8') + } catch { + continue + } + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + const match = assignment.exec(line) + if (!match?.[1]) { + continue + } + // Why: strip trailing unquoted `# comment` first so quoted values like + // `"$HOME/.opencode" # note` survive intact for unquoteShellValue. + const decommented = stripTrailingComment(match[1]) + const { text, quoted } = unquoteShellValue(decommented) + // Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would + // do for double-quoted and unquoted values; single-quoted is literal. + const expanded = quoted === "'" ? text : expandHome(text, home) + if (expanded.length > 0) { + lastMatch = expanded + } + } + } + + cache.set(cacheKey, lastMatch) + return lastMatch +} + +/** + * Test-only helper to reset the per-process cache between cases. + * Why: production callers never invalidate (rc files don't change at + * runtime), but tests need clean state per case. + */ +export function __resetShellStartupEnvCache(): void { + cache.clear() +} diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index ef827a820b4..b90568567ff 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -74,9 +74,17 @@ export class RateLimitService { private inactiveCodexFetching = new Set() private lastInactiveClaudeFetchAt = 0 private lastInactiveCodexFetchAt = 0 + private stateListeners = new Set<(state: RateLimitState) => void>() constructor() {} + onStateChange(listener: (state: RateLimitState) => void): () => void { + this.stateListeners.add(listener) + return () => { + this.stateListeners.delete(listener) + } + } + setCodexHomePathResolver(resolver: () => string | null): void { this.codexHomePathResolver = resolver } @@ -757,9 +765,17 @@ export class RateLimitService { } private pushToRenderer(): void { + const state = this.getState() + for (const listener of this.stateListeners) { + try { + listener(state) + } catch { + // ignore — one bad listener must not break the others + } + } if (!this.mainWindow || this.mainWindow.isDestroyed()) { return } - this.mainWindow.webContents.send('rateLimits:update', this.getState()) + this.mainWindow.webContents.send('rateLimits:update', state) } } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts new file mode 100644 index 00000000000..c925bce2b9c --- /dev/null +++ b/src/main/runtime/device-registry.ts @@ -0,0 +1,108 @@ +// Why: per-device tokens replace the shared runtime auth token for WebSocket +// (mobile) connections. Each paired device gets its own revocable token so +// compromising one device doesn't expose others. The registry is a simple +// JSON file with hardened permissions matching the runtime metadata pattern. +import { randomBytes, randomUUID } from 'crypto' +import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { join } from 'path' + +const DEVICE_REGISTRY_FILENAME = 'orca-devices.json' + +export type DeviceEntry = { + deviceId: string + name: string + token: string + pairedAt: number + lastSeenAt: number +} + +export class DeviceRegistry { + private readonly registryPath: string + private devices: DeviceEntry[] = [] + + constructor(userDataPath: string) { + this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME) + this.load() + } + + addDevice(name: string): DeviceEntry { + const entry: DeviceEntry = { + deviceId: randomUUID(), + name, + token: randomBytes(24).toString('hex'), + pairedAt: Date.now(), + lastSeenAt: 0 + } + this.devices.push(entry) + this.save() + return entry + } + + // Why: coalesce repeated QR-regenerate clicks onto a single pending token. + // Each call to addDevice() produces a valid auth credential; without + // coalescing, every renderer call to mobile:getPairingQR (e.g. the new + // copy-button flow that encourages regeneration) leaves an orphaned token + // forever. Returns an existing never-scanned entry if present; otherwise + // mints a new one and drops any stale pending entries. + getOrCreatePendingDevice(name: string): DeviceEntry { + const existing = this.devices.find((d) => d.lastSeenAt === 0) + if (existing) { + return existing + } + return this.addDevice(name) + } + + // Why: explicit rotation path for "Regenerate QR" — invalidates any + // existing never-scanned token (e.g. one that was screenshotted, copied + // to clipboard, or shown on a screen-share) and mints a fresh one. Without + // this, getOrCreatePendingDevice keeps returning the same token forever + // until a phone actually pairs, so users have no way to revoke a leaked + // pre-pairing token. + rotatePendingDevice(name: string): DeviceEntry { + this.devices = this.devices.filter((d) => d.lastSeenAt !== 0) + return this.addDevice(name) + } + + removeDevice(deviceId: string): boolean { + const before = this.devices.length + this.devices = this.devices.filter((d) => d.deviceId !== deviceId) + if (this.devices.length < before) { + this.save() + return true + } + return false + } + + listDevices(): readonly DeviceEntry[] { + return this.devices + } + + validateToken(token: string): DeviceEntry | null { + return this.devices.find((d) => d.token === token) ?? null + } + + updateLastSeen(deviceId: string): void { + const device = this.devices.find((d) => d.deviceId === deviceId) + if (device) { + device.lastSeenAt = Date.now() + this.save() + } + } + + private load(): void { + if (!existsSync(this.registryPath)) { + this.devices = [] + return + } + try { + this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] + } catch { + this.devices = [] + } + } + + private save(): void { + writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 }) + chmodSync(this.registryPath, 0o600) + } +} diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts new file mode 100644 index 00000000000..de0373ebafe --- /dev/null +++ b/src/main/runtime/e2ee-keypair.ts @@ -0,0 +1,50 @@ +// Why: the E2EE keypair enables application-layer encryption between mobile +// and desktop over plain ws://. The public key is embedded in the QR pairing +// offer so the mobile client can derive a shared secret via ECDH. +import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { join } from 'path' +import nacl from 'tweetnacl' + +const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' +const KEYPAIR_VERSION = 1 + +type KeypairFile = { + v: number + publicKeyB64: string + secretKeyB64: string +} + +export type E2EEKeypair = { + publicKey: Uint8Array + secretKey: Uint8Array + publicKeyB64: string +} + +export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { + const filePath = join(userDataPath, KEYPAIR_FILENAME) + + if (existsSync(filePath)) { + try { + const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) + if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { + const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) + const secretKey = Uint8Array.from(Buffer.from(raw.secretKeyB64, 'base64')) + if (publicKey.length === 32 && secretKey.length === 32) { + return { publicKey, secretKey, publicKeyB64: raw.publicKeyB64 } + } + } + } catch { + // Malformed file — regenerate below. + } + } + + const keypair = nacl.box.keyPair() + const publicKeyB64 = Buffer.from(keypair.publicKey).toString('base64') + const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64') + + const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 } + writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') + chmodSync(filePath, 0o600) + + return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 } +} diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts new file mode 100644 index 00000000000..4f60ffb4388 --- /dev/null +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: these tests cover the §3.3 Lifecycle rules on +// `OrcaRuntimeService.fetchRemoteWithCache` — in particular that a rejected +// fetch evicts its Map entry AND does not advance the freshness timestamp, +// and that two concurrent callers serialize on a single underlying fetch. +// They live in a dedicated file so we can mock `gitExecFileAsync` cleanly +// without disturbing the large orca-runtime.test.ts mock surface. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + gitExecFileAsync: gitExecFileAsyncMock + } +}) + +// Why: orca-runtime.ts imports heavy modules (hooks, ipc/*, etc.) at top +// level. We only exercise the fetch cache, so we let those imports load +// normally — none of them trigger IO until a runtime method is called. +import { OrcaRuntimeService } from './orca-runtime' + +function fetchCallCount(): number { + return gitExecFileAsyncMock.mock.calls.filter( + ([argv]) => Array.isArray(argv) && argv[0] === 'fetch' + ).length +} + +function mockFetchResults(results: (Promise | unknown)[]): void { + let fetchIndex = 0 + gitExecFileAsyncMock.mockImplementation((argv: string[]) => { + if (argv[0] === 'rev-parse') { + return Promise.reject(new Error('not a repo in cache-key test')) + } + const result = results[fetchIndex++] + return result instanceof Promise ? result : Promise.resolve(result) + }) +} + +describe('OrcaRuntimeService.fetchRemoteWithCache', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('evicts the in-flight Map entry on rejection so the next caller re-fetches', async () => { + // First call rejects, second call resolves. Without §3.3 Lifecycle + // `.finally()` eviction, the second caller would await the rejected + // promise forever (or throw the same error) — the regression pattern + // described in §3.3. + mockFetchResults([Promise.reject(new Error('network down')), { stdout: '', stderr: '' }]) + + const runtime = new OrcaRuntimeService(null) + + await runtime.fetchRemoteWithCache('/repo/a', 'origin') + await runtime.fetchRemoteWithCache('/repo/a', 'origin') + + expect(fetchCallCount()).toBe(2) + }) + + it('does not advance the freshness timestamp when the fetch rejects', async () => { + // A rejected fetch that wrote the timestamp would make the 30s freshness + // cache "lie" — the next caller would skip the fetch on a repo whose + // last real sync is unknown. §3.3 mandates success-only writes. + mockFetchResults([Promise.reject(new Error('boom')), { stdout: '', stderr: '' }]) + + const runtime = new OrcaRuntimeService(null) + + await runtime.fetchRemoteWithCache('/repo/b', 'origin') + // Immediately call again — if the freshness window were armed we would + // short-circuit and skip the fetch. It must still dispatch a real fetch. + await runtime.fetchRemoteWithCache('/repo/b', 'origin') + + expect(fetchCallCount()).toBe(2) + }) + + it('serializes two concurrent callers onto a single git fetch', async () => { + // Two callers hitting the same repo+remote at the same time must share + // one underlying fetch. Without the in-flight Map they would each + // dispatch an independent `git fetch`, tripling the network load in the + // worst case (renderer create + dispatch probe + CLI create). + let resolveFetch!: () => void + const pending = new Promise<{ stdout: string; stderr: string }>((resolve) => { + resolveFetch = () => resolve({ stdout: '', stderr: '' }) + }) + mockFetchResults([pending]) + + const runtime = new OrcaRuntimeService(null) + + const first = runtime.fetchRemoteWithCache('/repo/c', 'origin') + const second = runtime.fetchRemoteWithCache('/repo/c', 'origin') + + // Allow both callers to register before we resolve. + await Promise.resolve() + await Promise.resolve() + expect(fetchCallCount()).toBe(1) + + resolveFetch() + await Promise.all([first, second]) + + expect(fetchCallCount()).toBe(1) + }) + + it('skips the fetch inside the 30s freshness window after a successful fetch', async () => { + mockFetchResults([{ stdout: '', stderr: '' }]) + + const runtime = new OrcaRuntimeService(null) + + await runtime.fetchRemoteWithCache('/repo/d', 'origin') + await runtime.fetchRemoteWithCache('/repo/d', 'origin') + + // Second call must short-circuit on the freshness window (no new exec). + expect(fetchCallCount()).toBe(1) + }) + + it('resolves remote-tracking bases with longest configured remote matching', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'foo\nfoo/bar\norigin\n', stderr: '' }) + const runtime = new OrcaRuntimeService(null) + + await expect(runtime.resolveRemoteTrackingBase('/repo/e', 'foo/bar/main')).resolves.toEqual({ + remote: 'foo/bar', + branch: 'main', + ref: 'refs/remotes/foo/bar/main', + base: 'foo/bar/main' + }) + }) +}) diff --git a/src/main/runtime/fit-override-integration.test.ts b/src/main/runtime/fit-override-integration.test.ts new file mode 100644 index 00000000000..f4853cdb147 --- /dev/null +++ b/src/main/runtime/fit-override-integration.test.ts @@ -0,0 +1,329 @@ +/** + * Integration test for the mobile-fit override flow. + * Tests the full lifecycle: mobile-fit → restore → verify PTY resized. + */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]) +})) + +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null), + getGitUsername: vi.fn().mockReturnValue('') + } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +describe('fit override integration', () => { + it('full lifecycle: fit → getSize → restore → verify PTY dims', async () => { + const runtime = new OrcaRuntimeService(store) + const currentSize = { cols: 150, rows: 40 } + const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + currentSize.cols = cols + currentSize.rows = rows + resizes.push({ ptyId, cols, rows }) + return true + }, + getSize: () => ({ ...currentSize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + notifications.push({ ptyId, mode, cols, rows }) + }, + terminalDriverChanged: vi.fn() + }) + + // Simulate a synced leaf (mounted desktop pane) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Terminal', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + console.log('=== Step 1: Initial state ===') + console.log('PTY size:', currentSize) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + + console.log('\n=== Step 2: Mobile fit to 45x20 ===') + const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) + console.log('Fit result:', fitResult) + console.log('PTY size after fit:', currentSize) + console.log('Override:', runtime.getTerminalFitOverride('pty-1')) + expect(currentSize).toEqual({ cols: 45, rows: 20 }) + expect(fitResult.previousCols).toBe(150) + expect(fitResult.previousRows).toBe(40) + + console.log('\n=== Step 3: Desktop Restore (via IPC handler path) ===') + // Simulate what runtime:restoreTerminalFit IPC handler does + const override = runtime.getTerminalFitOverride('pty-1') + expect(override).not.toBeNull() + const restoreResult = await runtime.resizeForClient('pty-1', 'restore', override!.clientId) + console.log('Restore result:', restoreResult) + console.log('PTY size after restore:', currentSize) + console.log('Override after restore:', runtime.getTerminalFitOverride('pty-1')) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + expect(restoreResult.mode).toBe('desktop-fit') + + console.log('\n=== Step 4: Verify all resizes ===') + console.log('All resize calls:', resizes) + expect(resizes).toEqual([ + { ptyId: 'pty-1', cols: 45, rows: 20 }, + { ptyId: 'pty-1', cols: 150, rows: 40 } + ]) + + console.log('\n=== Step 5: Verify notifications ===') + console.log('All notifications:', notifications) + expect(notifications).toEqual([ + { ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }, + { ptyId: 'pty-1', mode: 'desktop-fit', cols: 150, rows: 40 } + ]) + + console.log('\n=== Step 6: Mobile restore via RPC path ===') + // Re-fit, then restore via the mobile RPC handler path + await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) + expect(currentSize).toEqual({ cols: 45, rows: 20 }) + + // This is what terminal.resizeForClient RPC handler does + const mobileRestore = await runtime.resizeForClient('pty-1', 'restore', 'client-phone') + console.log('Mobile restore result:', mobileRestore) + console.log('PTY size after mobile restore:', currentSize) + expect(currentSize).toEqual({ cols: 150, rows: 40 }) + }) + + it('restore resizes PTY even with mounted leaf (the bug fix)', async () => { + const runtime = new OrcaRuntimeService(store) + let ptySize = { cols: 120, rows: 35 } + const resizes: string[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + ptySize = { cols, rows } + resizes.push(`${ptyId}:${cols}x${rows}`) + return true + }, + getSize: () => ({ ...ptySize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + // Synced leaf = mounted desktop pane + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + // Mobile fit + await runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-a', 42, 18) + expect(ptySize).toEqual({ cols: 42, rows: 18 }) + + // Restore — THIS is the critical assertion. + // Before the fix, mounted leaves skipped the PTY resize. + await runtime.resizeForClient('pty-1', 'restore', 'phone-a') + expect(ptySize).toEqual({ cols: 120, rows: 35 }) + expect(resizes).toEqual(['pty-1:42x18', 'pty-1:120x35']) + }) + + it('disconnect auto-restore also resizes PTY (when auto-restore is configured)', async () => { + // Why: indefinite hold (mobileAutoRestoreFitMs=null) is the default and + // intentionally suppresses disconnect-driven auto-restore so the desktop + // banner stays mounted after the phone leaves. This test verifies the + // legacy auto-restore path still works when the user opts into a finite + // window. See docs/mobile-fit-hold.md. + const finiteRestoreStore = { + ...store, + getSettings: () => ({ ...store.getSettings(), mobileAutoRestoreFitMs: 5_000 }) + } + const runtime = new OrcaRuntimeService(finiteRestoreStore) + let ptySize = { cols: 100, rows: 30 } + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (_ptyId, cols, rows) => { + ptySize = { cols, rows } + return true + }, + getSize: () => ({ ...ptySize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + await runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20) + expect(ptySize).toEqual({ cols: 45, rows: 20 }) + + // Simulate WS disconnect + runtime.onClientDisconnected('phone-disconnect') + // onClientDisconnected enqueues fire-and-forget; flush microtasks + await new Promise((r) => setTimeout(r, 0)) + expect(ptySize).toEqual({ cols: 100, rows: 30 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + }) + + it('disconnect with indefinite hold (default) keeps PTY at phone dims', async () => { + const runtime = new OrcaRuntimeService(store) + let ptySize = { cols: 100, rows: 30 } + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (_ptyId, cols, rows) => { + ptySize = { cols, rows } + return true + }, + getSize: () => ({ ...ptySize }) + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + // Default mobileAutoRestoreFitMs is null (indefinite hold). + await runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20) + expect(ptySize).toEqual({ cols: 45, rows: 20 }) + + runtime.onClientDisconnected('phone-disconnect') + await new Promise((r) => setTimeout(r, 0)) + + // PTY stays at phone dims and override persists — desktop banner remains. + expect(ptySize).toEqual({ cols: 45, rows: 20 }) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + }) +}) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts new file mode 100644 index 00000000000..d3a84349f2c --- /dev/null +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -0,0 +1,356 @@ +/** + * Tests for the presence-based mobile driver lock. + * + * Covers (per docs/mobile-presence-lock.md): + * - State machine transitions (idle | desktop | mobile{clientId}) + * - Most-recent-actor wins for active phone-fit dims + * - Earliest-by-subscribe-time wins for desktop-restore target + * - Subscribe-in-desktop-mode is a passive watch (does NOT take floor) + * - mobileTookFloor → re-applies phone-fit when transitioning from desktop + * - reclaimTerminalForDesktop → idempotent, drops banner, restores dims + * - Multi-mobile sequencing: A subscribes / B subscribes / B unsubscribes / + * A unsubscribes leaves the runtime cleanly idle without dim regressions + * - terminalDriverChanged notifications fire at the right transitions + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]) +})) +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) +vi.mock('../ipc/filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null), + getGitUsername: vi.fn().mockReturnValue('') + } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + // Why: legacy mobile tests pre-date the fit-hold preference. Default + // to MIN (the new clamp floor) so the auto-restore behavior they assert + // continues to fire after a finite delay. Real getDefaultSettings() + // is null/indefinite. See docs/mobile-fit-hold.md. + mobileAutoRestoreFitMs: 5_000 + }) +} + +function createRuntime() { + const runtime = new OrcaRuntimeService(store) + const ptySizes = new Map([ + ['pty-1', { cols: 150, rows: 40 }] + ]) + const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + ptySizes.set(ptyId, { cols, rows }) + resizes.push({ ptyId, cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: (ptyId, driver) => { + driverEvents.push({ ptyId, driver: { ...driver } }) + } + }) + + return { runtime, ptySizes, resizes, driverEvents } +} + +describe('mobile presence lock — driver state machine', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('starts idle for unknown PTY', () => { + const { runtime } = createRuntime() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('handleMobileSubscribe in auto mode transitions idle → mobile{clientId}', async () => { + const { runtime, driverEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + }) + + it('handleMobileSubscribe in desktop mode is passive — does NOT take floor', async () => { + const { runtime, driverEvents } = createRuntime() + // Pretend a previous take-back put us in desktop mode. + runtime.setMobileDisplayMode('pty-1', 'desktop') + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + // Driver stays idle (the desktop banner was already gone). Phone is + // "passively watching" at desktop dims. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(driverEvents.find((e) => e.driver.kind === 'mobile')).toBeUndefined() + }) + + it('reclaimTerminalForDesktop transitions mobile → desktop and is idempotent', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1').kind).toBe('mobile') + + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + + // Idempotent — second call is a no-op (no active mobile subscriber to + // reclaim from). + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('mobileTookFloor after reclaim re-applies phone-fit and flips driver back to mobile', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.getDriver('pty-1').kind).toBe('desktop') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + await runtime.mobileTookFloor('pty-1', 'phone-A') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + // PTY is back at phone dims. + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', async () => { + const { runtime, driverEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + // Why: soft-leave grace keeps driver=mobile{phone-A} for ~250ms so a + // re-subscribe (e.g. mobile keyboard show/hide on legacy clients) + // doesn't cause a desktop banner flash. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + await vi.advanceTimersByTimeAsync(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'idle' }) + }) + + it('resubscribe within soft-leave grace cancels idle without driver flap', async () => { + const { runtime, driverEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // Same client re-subscribes inside the grace window — no idle should + // ever be observed by the renderer. + await vi.advanceTimersByTimeAsync(100) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(500) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(driverEvents.find((e) => e.driver.kind === 'idle')).toBeUndefined() + }) + + it('onPtyExit clears driver state and emits idle', async () => { + const { runtime, driverEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + runtime.onPtyExit('pty-1', 0) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + // The last emitted event for pty-1 must be idle. + const last = [...driverEvents].reverse().find((e) => e.ptyId === 'pty-1') + expect(last?.driver).toEqual({ kind: 'idle' }) + }) +}) + +describe('mobile presence lock — multi-mobile semantics', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Advance fake clock so B's subscribedAt is strictly greater than A's. + await vi.advanceTimersByTimeAsync(10) + // B's narrower viewport must win when it subscribes. + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 38, rows: 18 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + }) + + it('B unsubscribes — A still present, driver re-elects to A', async () => { + const { runtime, driverEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-B') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + }) + + it('A then B unsubscribes — peer survives; final unsubscribe goes idle', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-B') + // Last leaver enters soft-grace; advance past it before asserting idle. + await vi.advanceTimersByTimeAsync(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('terminal.send by phone-B updates lastActedAt — applyMobileDisplayMode picks B viewport', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + // Advance the fake clock so phone-B's subscribe records a strictly + // later subscribedAt/lastActedAt — keeps tie-break deterministic. + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + // Switch to desktop, then phone-B types — its viewport wins on re-fit. + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + // Simulate B taking the floor by typing (advance again so lastActedAt + // is unambiguously the most recent). + await vi.advanceTimersByTimeAsync(10) + await runtime.mobileTookFloor('pty-1', 'phone-B') + + expect(ptySizes.get('pty-1')).toEqual({ cols: 38, rows: 18 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + }) + + it('updateMobileViewport re-fits PTY without flipping the driver', async () => { + const { runtime, ptySizes, driverEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + const before = driverEvents.length + + // Keyboard opens — viewport shrinks. + expect(await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })).toBe( + true + ) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 16 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + // Why: a viewport update may re-emit driver to refresh listener + // wiring, but it must never go through `idle` (no banner flash). + expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true) + }) + + it('updateMobileViewport then disconnect restores PTY to original baseline', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) + + // Keyboard cycles a few times. + await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 }) + await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 38 }) + await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 }) + + // Phone disconnects (router.back → WS close). + runtime.onClientDisconnected('phone-A') + // onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers. + await vi.advanceTimersByTimeAsync(0) + + // PTY must restore to the original 150x40 baseline, not the last + // phone-fit dim. This was the stuck-dim bug. + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('legacy unsubscribe → resubscribe within grace preserves baseline (regression)', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) + + // Simulate legacy keyboard re-subscribe cycle within grace. + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(100) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 16 }) + // Apply mode after re-subscribe so the new viewport drives PTY dims. + await runtime.applyMobileDisplayMode('pty-1') + + // PTY at new viewport, phone-A still drives. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // Disconnect — must restore to original 150x40, not 49x16. + runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(0) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('earliest-subscribe restore target is preserved when peers churn', async () => { + const { runtime, ptySizes } = createRuntime() + // A captures the original 150x40 baseline. + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + // B subscribes later at 38x18 (advance clock for unambiguous ordering). + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + // A leaves, B leaves — final restore must use A's earliest baseline (150x40), + // NOT B's (which captured 45x20 when it joined a phone-fitted PTY). + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + runtime.handleMobileUnsubscribe('pty-1', 'phone-B') + await vi.advanceTimersByTimeAsync(5_000) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) +}) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts new file mode 100644 index 00000000000..ffb6d2e1c94 --- /dev/null +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -0,0 +1,863 @@ +/* oxlint-disable max-lines -- Why: integration tests cover the full mobile subscribe lifecycle across many scenarios; splitting would scatter related assertions. */ +/** + * Integration tests for the server-authoritative mobile subscribe lifecycle. + * Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode, + * debounced restore, inline restore on timer cancel, and cleanup paths. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]) +})) + +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) + +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) + +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null), + getGitUsername: vi.fn().mockReturnValue('') + } +}) + +// Why: many tests pre-date the mobileAutoRestoreFitMs preference. Default +// the mock store to MIN (5_000ms — the new clamp floor) so legacy +// assertions about "restore fires after the configured delay" keep their +// shape while new tests can override per-test. Indefinite/null is the +// real-world default and is exercised by a dedicated test below. +const LEGACY_RESTORE_MS = 5_000 +const settingsState = { + mobileAutoRestoreFitMs: LEGACY_RESTORE_MS as number | null +} + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + mobileAutoRestoreFitMs: settingsState.mobileAutoRestoreFitMs + }), + updateSettings: (updates: { mobileAutoRestoreFitMs?: number | null }) => { + if ('mobileAutoRestoreFitMs' in updates) { + settingsState.mobileAutoRestoreFitMs = updates.mobileAutoRestoreFitMs ?? null + } + } +} + +function createRuntime() { + const runtime = new OrcaRuntimeService(store) + const ptySizes = new Map() + ptySizes.set('pty-1', { cols: 150, rows: 40 }) + ptySizes.set('pty-2', { cols: 120, rows: 35 }) + ptySizes.set('pty-3', { cols: 100, rows: 30 }) + + const resizes: { ptyId: string; cols: number; rows: number }[] = [] + const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + ptySizes.set(ptyId, { cols, rows }) + resizes.push({ ptyId, cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + notifications.push({ ptyId, mode, cols, rows }) + }, + terminalDriverChanged: vi.fn() + }) + + return { runtime, ptySizes, resizes, notifications } +} + +describe('mobile subscribe integration', () => { + beforeEach(() => { + vi.useFakeTimers() + settingsState.mobileAutoRestoreFitMs = LEGACY_RESTORE_MS + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('handleMobileSubscribe resizes PTY to phone dims', async () => { + const { runtime, ptySizes, resizes, notifications } = createRuntime() + + const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', { + cols: 45, + rows: 20 + }) + + expect(result).toBe(true) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(resizes).toEqual([{ ptyId: 'pty-1', cols: 45, rows: 20 }]) + expect(notifications).toEqual([{ ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }]) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true) + }) + + it('handleMobileSubscribe skips resize when mode is desktop', async () => { + const { runtime, ptySizes, resizes } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'desktop') + + const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', { + cols: 45, + rows: 20 + }) + + expect(result).toBe(false) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizes).toEqual([]) + }) + + it('handleMobileSubscribe skips resize when no viewport provided', async () => { + const { runtime, ptySizes, resizes } = createRuntime() + + const result = await runtime.handleMobileSubscribe('pty-1', 'client-a') + + expect(result).toBe(false) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizes).toEqual([]) + }) + + it('handleMobileUnsubscribe restores PTY after debounce in auto mode', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Not yet restored + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + // Why: 'phone' (sticky-fit) mode was removed — there are now only 'auto' + // and 'desktop'. Auto-mode always restores on last unsubscribe. Test + // kept and inverted to lock in the new contract. + it('handleMobileUnsubscribe restores after auto-mode last unsubscribe', async () => { + const { runtime, ptySizes } = createRuntime() + // mode defaults to 'auto' + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS) + // Restored to desktop dims — no sticky-phone retention. + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + // TODO: inline restore on re-subscribe not yet implemented + it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // Re-subscribe to a different terminal before the timer fires + await vi.advanceTimersByTimeAsync(100) + await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + // pty-1 was inline-restored when pty-2 subscribed (timer cancelled + immediate restore) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 }) + + // Advancing past the 300ms debounce should not cause a second restore + await vi.advanceTimersByTimeAsync(300) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + // TODO: inline restore on re-subscribe not yet implemented + it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', async () => { + const { runtime, ptySizes } = createRuntime() + + // Subscribe to A + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Unsubscribe A, subscribe B — A's pending timer is cancelled, A gets inline restore + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + // pty-1 should be restored inline (not waiting for timer) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 }) + + // Unsubscribe B, subscribe C — B's pending timer cancelled, B gets inline restore + runtime.handleMobileUnsubscribe('pty-2', 'client-a') + await runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 }) + + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 }) + + // Verify final state + await vi.advanceTimersByTimeAsync(1000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 }) + }) + + it('preserves previousDims across re-subscribes to same terminal', async () => { + const { runtime, ptySizes } = createRuntime() + + // First subscribe at desktop 150x40 + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Re-subscribe to the same terminal (e.g., after reconnect) + // The PTY is already at 45x20, but previousDims should still be 150x40 + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Unsubscribe and let restore fire + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS) + + // Should restore to original desktop dims, not 45x20 + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('preserves held-override baseline across resubscribe-after-indefinite-hold', async () => { + // Why: when the last mobile subscriber leaves under indefinite hold, + // the inner-subscribers map is wiped but `terminalFitOverrides` retains + // the original desktop dims as previousCols/previousRows. A fresh + // resubscribe must inherit those — otherwise rendererSize/currentSize + // (both phone dims because the override held them) would replace the + // baseline with phone dims, and any subsequent desktop "Restore" would + // be a no-op (restore-target == current dims). + settingsState.mobileAutoRestoreFitMs = null // indefinite hold + const { runtime, ptySizes } = createRuntime() + + // Initial subscribe at desktop 150x40 → fit to 45x20. + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Phone leaves; indefinite hold keeps PTY at phone dims with no + // subscribers. Override baseline still carries 150x40. + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + await vi.advanceTimersByTimeAsync(60_000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Phone reopens — fresh resubscribe (inner map is empty). + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Desktop user clicks Restore. + await runtime.reclaimTerminalForDesktop('pty-1') + + // Must restore to the original 150x40, not the phone-fit 45x20. + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('clamps viewport to valid range', async () => { + const { runtime, ptySizes } = createRuntime() + + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 }) + // Should clamp to minimum 20x8 + expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 }) + }) + + describe('display mode', () => { + it('defaults to auto', () => { + const { runtime } = createRuntime() + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + }) + + it('set/get round-trip', () => { + const { runtime } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'desktop') + expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop') + + // Setting to 'auto' deletes the entry (same as default) + runtime.setMobileDisplayMode('pty-1', 'auto') + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + }) + }) + + describe('applyMobileDisplayMode', () => { + it('desktop mode restores PTY when currently phone-fitted', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + const resizeEvents: unknown[] = [] + runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event)) + + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizeEvents).toHaveLength(1) + expect(resizeEvents[0]).toMatchObject({ + cols: 150, + rows: 40, + displayMode: 'desktop', + reason: 'apply-layout' + }) + }) + + it('auto mode re-fits PTY when subscriber exists and not phone-fitted', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Switch to desktop (restores to 150x40) + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + const resizeEvents: unknown[] = [] + runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event)) + + // Switch back to auto (should re-fit to phone dims) + runtime.setMobileDisplayMode('pty-1', 'auto') + await runtime.applyMobileDisplayMode('pty-1') + + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(resizeEvents).toHaveLength(1) + expect(resizeEvents[0]).toMatchObject({ + displayMode: 'phone', + reason: 'apply-layout' + }) + }) + }) + + describe('cleanup paths', () => { + it('onClientDisconnected restores all PTYs immediately (no debounce)', async () => { + const { runtime, ptySizes } = createRuntime() + + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + + runtime.onClientDisconnected('client-a') + // onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers. + await vi.advanceTimersByTimeAsync(0) + + // Both PTYs restored immediately + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false) + }) + + it('onClientDisconnected cancels pending restore timers', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Timer is pending + + runtime.onClientDisconnected('client-a') + // Timer should be cancelled, PTY already restored by disconnect handler + + await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('onPtyExit cleans up mobileSubscribers and pending timers', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + // Timer pending for pty-1 + + runtime.onPtyExit('pty-1', 0) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + + // Timer should have been cancelled — no crash from resizing a dead PTY + await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS) + }) + + it('onPtyExit does not cancel timers for other PTYs', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // pty-2 exits — should not affect pty-1's pending restore + runtime.onPtyExit('pty-2', 0) + + await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + }) + + describe('resize listener system', () => { + it('subscribe/unsubscribe lifecycle', async () => { + const { runtime } = createRuntime() + const events: unknown[] = [] + const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + + expect(events.length).toBeGreaterThan(0) + + const countBefore = events.length + unsubscribe() + + // After unsubscribe, no more events + runtime.setMobileDisplayMode('pty-1', 'auto') + await runtime.applyMobileDisplayMode('pty-1') + expect(events.length).toBe(countBefore) + }) + }) + + describe('onExternalPtyResize', () => { + it('updates previousCols when desktop renderer resizes PTY after desktop restore', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Toggle to desktop — restores to previousCols (150x40) + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + // Phone→desktop arms the 500ms renderer-cascade suppress window per + // docs/mobile-terminal-layout-state-machine.md. Wait it out before the + // renderer's correcting fit is allowed to update lastRendererSizes. + await vi.advanceTimersByTimeAsync(500) + + // Simulate desktop renderer's safeFit correcting to split-pane width + runtime.onExternalPtyResize('pty-1', 105, 40) + + // Toggle back to auto — should capture previousCols=105 (not 150) + runtime.setMobileDisplayMode('pty-1', 'auto') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Toggle to desktop again — should restore to 105 (the corrected value) + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 }) + }) + + it('uses lastRendererSize for previousCols on first subscribe', async () => { + const { runtime, ptySizes } = createRuntime() + + // Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105 + ptySizes.set('pty-1', { cols: 214, rows: 72 }) + runtime.onExternalPtyResize('pty-1', 105, 40) + + // First mobile subscribe — should use rendererSize (105) not ptySizes (214) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Toggle to desktop — should restore to 105, not 214 + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 }) + }) + + it('ignores reports while a mobile-fit override is in place', async () => { + // Why: while a mobile-fit override is in place, the PTY is parked at + // phone dims and the desktop renderer's safeFit will report those + // phone dims back to us. Treating that as "external" geometry would + // overwrite the subscriber's previousCols/Rows baseline with phone + // dims; resolveDesktopRestoreTarget would then return phone dims on + // the next "Restore" click, leaving xterm stuck at phone dims after + // a no-op desktop-restore. See docs/mobile-fit-hold.md. + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Renderer reports phone dims back (echo of override). Must be ignored. + runtime.onExternalPtyResize('pty-1', 45, 20) + + // Toggle to desktop — restore lands on the original desktop baseline, + // not the phone-dim echo. + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('still updates baseline once the override is cleared (real desktop resize)', async () => { + // Counterpart to the above: after the user takes back, the renderer's + // pty:resize events ARE legitimate geometry reports and must update + // the baseline used by the next phone-fit cycle. + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Take back — clears override and arms the 500ms cascade-suppress. + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + // Wait past the renderer-cascade suppress window. + await vi.advanceTimersByTimeAsync(500) + + // User resizes the desktop window. Renderer fires pty:resize. + runtime.onExternalPtyResize('pty-1', 130, 35) + + // Toggle back to phone, then take back again — should restore to the + // updated desktop geometry, not the original 150x40. + runtime.setMobileDisplayMode('pty-1', 'auto') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 130, rows: 35 }) + }) + + it('updates baseline mid-fit when desktop reports dims that differ from the override', async () => { + // Why: a previously-hidden desktop tab can become visible while the + // phone is still phone-fitting (e.g. user activates the tab on + // desktop). The pane's container goes 0×0 → real geometry, fitAddon + // measures, and pty:resize fires with REAL dims (not the override's + // phone dims). That report is legitimate and must refresh the + // restore baseline so take-back lands on the visible desktop + // geometry instead of whatever stale baseline the subscriber + // captured at first subscribe. + const { runtime, ptySizes } = createRuntime() + // Pre-populate with an old/stale baseline (e.g. spawn default 80×24) + // by first reporting it before subscribe, then subscribing. + ptySizes.set('pty-1', { cols: 80, rows: 24 }) + runtime.onExternalPtyResize('pty-1', 80, 24) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Desktop tab gets activated mid-fit; renderer reports real dims. + // These differ from the override (45×20), so they must pass through. + runtime.onExternalPtyResize('pty-1', 200, 60) + + // Take back — should land on 200×60, not 80×24. + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 200, rows: 60 }) + }) + }) + + describe('recordRendererGeometry (pty:reportGeometry IPC)', () => { + it('refreshes subscriber baseline while a mobile-fit override is active', async () => { + // Why: backs the partial-restore-width fix. When phone subscribes to + // a never-desktop-active terminal, the subscriber baseline is the + // PTY spawn default (e.g. 80×24). The renderer's measurement-only + // report (sent when the desktop pane finally measures real geometry) + // must update the baseline so Take Back restores to real dims. + const { runtime, ptySizes } = createRuntime() + ptySizes.set('pty-1', { cols: 80, rows: 24 }) + runtime.onExternalPtyResize('pty-1', 80, 24) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // Override is now in place (phone-fit). Desktop pane becomes visible + // and measures real geometry. The renderer reports it via the + // measurement-only channel. + runtime.recordRendererGeometry('pty-1', 214, 72) + + // Take back — should restore to the reported dims, not 80×24. + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 214, rows: 72 }) + }) + + it('updates lastRendererSizes for a never-subscribed PTY', () => { + // Why: handleMobileSubscribe's previousCols fallback chain reads + // lastRendererSizes. A geometry report fired before the first + // subscribe must populate that cache so the subscriber's baseline + // captures real dims, not the spawn default. + const { runtime } = createRuntime() + runtime.recordRendererGeometry('pty-99', 180, 50) + + const renderer = runtime.getLastRendererSize('pty-99') + expect(renderer).toEqual({ cols: 180, rows: 50 }) + }) + + it('ignores non-positive dims', () => { + const { runtime } = createRuntime() + runtime.recordRendererGeometry('pty-1', 0, 0) + runtime.recordRendererGeometry('pty-1', -5, 10) + runtime.recordRendererGeometry('pty-1', 10, -5) + expect(runtime.getLastRendererSize('pty-1')).toBeNull() + }) + + it('bypasses the cascade-suppress window (it is measurement-only)', async () => { + // Why: pty:resize is gated by a 500ms suppress to absorb the safeFit + // cascade after a mode flip. The measurement-only channel must not + // be gated — its whole purpose is to deliver a fresh measurement + // when the renderer detects the pane container has finally settled + // to real geometry, including potentially right after a flip. + const { runtime } = createRuntime() + // Arm cascade-suppress. + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.applyMobileDisplayMode('pty-1') + // Within the 500ms window: + runtime.recordRendererGeometry('pty-1', 130, 40) + expect(runtime.getLastRendererSize('pty-1')).toEqual({ cols: 130, rows: 40 }) + }) + }) + + describe('backward compatibility', () => { + it('old resizeForClient still works alongside new system', async () => { + const { runtime, ptySizes } = createRuntime() + + // Old flow: explicit resizeForClient + const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(fitResult.mode).toBe('mobile-fit') + + // Old flow: restore + const restoreResult = await runtime.resizeForClient('pty-1', 'restore', 'client-old') + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(restoreResult.mode).toBe('desktop-fit') + }) + }) + + // See docs/mobile-fit-hold.md. + describe('mobileAutoRestoreFitMs (fit hold)', () => { + it('null (indefinite) keeps PTY at phone dims after last unsubscribe', async () => { + settingsState.mobileAutoRestoreFitMs = null + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // Wait long past the legacy 300ms debounce — PTY must remain held. + await vi.advanceTimersByTimeAsync(60_000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('finite ms restores after the configured delay', async () => { + settingsState.mobileAutoRestoreFitMs = 60_000 + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + await vi.advanceTimersByTimeAsync(30_000) + // Not yet — still mid-window. + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('re-subscribe before timer fires cancels pending restore', async () => { + settingsState.mobileAutoRestoreFitMs = 60_000 + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + await vi.advanceTimersByTimeAsync(30_000) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + // The pending timer should have been cancelled by the new subscribe. + // Wait long past what would've been the restore moment. + await vi.advanceTimersByTimeAsync(120_000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('reclaimTerminalForDesktop with active subscriber resets mode so next subscribe re-fits', async () => { + // Why: desktop "Take back" while the phone is actively driving sets + // mobileDisplayMode='desktop' to drive the layout transition. Without + // resetting it, the next mobile subscribe (e.g. user switches tabs + // back on the phone) sees mode='desktop' and enters passive watch, + // never re-fitting the PTY to phone dims. The phone then renders the + // desktop-dim scrollback echoed back at it. See docs/mobile-fit-hold.md. + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Desktop reclaim while phone is still subscribed. + const ok = await runtime.reclaimTerminalForDesktop('pty-1') + expect(ok).toBe(true) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + // Mode reset so a re-subscribe takes the auto path. + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + + // Mobile re-subscribes (e.g. tab switch). Must re-fit to phone dims. + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('reclaim with WS disconnect between cycles — held branch restores correctly each time', async () => { + // Why: regression for "subsequent take-back stuck on phone dims" with + // indefinite hold. The realistic flow is: + // subscribe → desktop reclaim → WS disconnect (background app) → + // phone re-subscribes → WS disconnect → desktop reclaim (held branch) + // The held branch must restore to real desktop dims every time, not + // get stuck reading current PTY size (= phone dims). + settingsState.mobileAutoRestoreFitMs = null + const { runtime, ptySizes } = createRuntime() + + for (let i = 0; i < 3; i++) { + // Phone subscribes, fits to phone dims. + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1'), `iter ${i}: phone-fit`).toEqual({ cols: 45, rows: 20 }) + + // Phone WS disconnects (e.g. backgrounding). With indefinite hold, + // PTY stays at phone dims and override persists; subscriber is + // removed from inner. + runtime.onClientDisconnected('client-a') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(60_000) + expect(ptySizes.get('pty-1'), `iter ${i}: held after disconnect`).toEqual({ + cols: 45, + rows: 20 + }) + expect(runtime.isMobileSubscriberActive('pty-1'), `iter ${i}: no subscribers`).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override held`).not.toBeNull() + + // Desktop clicks Restore — held-override branch. + const ok = await runtime.reclaimTerminalForDesktop('pty-1') + expect(ok, `iter ${i}: reclaim ok`).toBe(true) + expect(ptySizes.get('pty-1'), `iter ${i}: PTY restored to desktop`).toEqual({ + cols: 150, + rows: 40 + }) + expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override cleared`).toBeNull() + } + }) + + it('reclaim → re-subscribe → reclaim cycle works repeatedly', async () => { + // Why: regression for "subsequent take-back doesn't change dims, stuck + // at phone dims". The full ping-pong must work N times, not just once. + const { runtime, ptySizes } = createRuntime() + + for (let i = 0; i < 3; i++) { + // Phone takes the floor (or first subscribe). + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1'), `iter ${i}: phone-fit`).toEqual({ cols: 45, rows: 20 }) + + // Desktop reclaims. + const ok = await runtime.reclaimTerminalForDesktop('pty-1') + expect(ok, `iter ${i}: reclaim ok`).toBe(true) + expect(ptySizes.get('pty-1'), `iter ${i}: reclaimed to desktop`).toEqual({ + cols: 150, + rows: 40 + }) + expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override cleared`).toBeNull() + } + }) + + it('null (indefinite) keeps PTY at phone dims when WS connection closes (onClientDisconnected)', async () => { + // Why: backgrounding the mobile app eventually closes the WebSocket, + // which routes through onClientDisconnected (NOT handleMobileUnsubscribe). + // The disconnect path predates indefinite-hold; without explicit gates + // it would unconditionally restore the PTY to desktop dims and clear + // the override, unmounting the desktop banner. + settingsState.mobileAutoRestoreFitMs = null + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + + runtime.onClientDisconnected('client-a') + await vi.advanceTimersByTimeAsync(0) + // Flush soft-leave grace too — that path also has its own desktop-restore branch. + await vi.advanceTimersByTimeAsync(60_000) + + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + }) + + it('reclaimTerminalForDesktop returns held PTY to desktop dims (no subscriber)', async () => { + settingsState.mobileAutoRestoreFitMs = null + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // No subscribers, indefinite hold — PTY stays at phone dims. + await vi.advanceTimersByTimeAsync(60_000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + + // Manual reclaim: PTY restored to desktop dims via the held branch. + const ok = await runtime.reclaimTerminalForDesktop('pty-1') + expect(ok).toBe(true) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('setMobileAutoRestoreFitMs(null) clears all pending timers', async () => { + settingsState.mobileAutoRestoreFitMs = 60_000 + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // Pending timer exists. Switch preference → indefinite. + runtime.setMobileAutoRestoreFitMs(null) + // Now wait far longer than the original 60s window. No restore fires. + await vi.advanceTimersByTimeAsync(120_000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('setMobileAutoRestoreFitMs to a finite value does not retroactively schedule', async () => { + settingsState.mobileAutoRestoreFitMs = null + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + // Indefinite hold — no timer scheduled at unsubscribe. + // Switch preference → 60s. The already-held PTY is NOT auto-restored; + // the new value applies to the *next* unsubscribe. + runtime.setMobileAutoRestoreFitMs(60_000) + await vi.advanceTimersByTimeAsync(120_000) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('setMobileAutoRestoreFitMs clamps below MIN to MIN', () => { + const { runtime } = createRuntime() + const result = runtime.setMobileAutoRestoreFitMs(100) + expect(result).toBe(5_000) + }) + + it('setMobileAutoRestoreFitMs clamps above MAX to MAX', () => { + const { runtime } = createRuntime() + const result = runtime.setMobileAutoRestoreFitMs(99 * 60 * 60 * 1000) + expect(result).toBe(60 * 60 * 1000) + }) + + it('reclaimTerminalForDesktop on already-restored PTY returns false', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS) + // Now restored to desktop — no subscriber, no override. + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 744568a7a64..ba6a643d975 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/types' import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' -import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' +import { + createSetupRunnerScript, + getEffectiveHooks, + runHook, + shouldRunSetupForCreate +} from '../hooks' import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' @@ -39,7 +44,12 @@ vi.mock('../git/worktree', () => ({ vi.mock('../hooks', () => ({ createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), - runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }), + shouldRunSetupForCreate: vi + .fn() + .mockImplementation((_repo: never, decision: string) => decision === 'run'), + getEffectiveSetupRunPolicy: vi.fn().mockReturnValue('auto'), + hasHooksFile: vi.fn().mockReturnValue(false) })) vi.mock('../ipc/worktree-logic', async (importOriginal) => { @@ -77,6 +87,8 @@ afterEach(() => { vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(runHook).mockReset() + vi.mocked(shouldRunSetupForCreate).mockReset() + vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run') vi.mocked(getEffectiveHooks).mockReturnValue(null) computeWorktreePathMock.mockReset() ensurePathWithinWorkspaceMock.mockReset() @@ -156,6 +168,7 @@ const store = { ...meta }) as never, removeWorktreeMeta: () => {}, + getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: '/tmp/workspaces', nestWorkspaces: false, @@ -196,6 +209,16 @@ describe('OrcaRuntimeService', () => { expect(runtime.getRuntimeId()).toBeTruthy() }) + it('reports protocol version and minimum compatible mobile version on status', () => { + const runtime = createRuntime() + + const status = runtime.getStatus() + expect(typeof status.protocolVersion).toBe('number') + expect(typeof status.minCompatibleMobileVersion).toBe('number') + expect(status.protocolVersion).toBeGreaterThanOrEqual(1) + expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0) + }) + it('claims the first window as authoritative and ignores later windows', () => { const runtime = createRuntime() @@ -378,6 +401,305 @@ describe('OrcaRuntimeService', () => { expect(writes).toEqual(['continue', '\r']) }) + it('creates visible terminal sessions without asking the renderer to focus a tab', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const createTerminal = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal, + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const result = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + title: 'worker' + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: TEST_WORKTREE_PATH, + command: 'codex', + worktreeId: TEST_WORKTREE_ID, + preAllocatedHandle: expect.stringMatching(/^term_/) + }) + ) + expect(result).toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + title: 'worker', + surface: 'visible' + }) + expect(result.handle).toMatch(/^term_/) + expect(createTerminal).not.toHaveBeenCalled() + expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { + ptyId: 'pty-bg', + title: 'worker', + activate: false + }) + }) + + it('creates background terminal sessions while the renderer graph is unavailable', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect(runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + surface: 'background' + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: TEST_WORKTREE_ID + }) + ) + }) + + it('returns a background handle when inactive tab adoption fails after spawn', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const revealTerminalSession = vi.fn().mockRejectedValue(new Error('Renderer timed out')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + try { + await expect(runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + surface: 'background', + handle: expect.stringMatching(/^term_/) + }) + expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { + ptyId: 'pty-bg', + title: null, + activate: false + }) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[terminal-create] failed to create inactive tab for pty-bg:'), + expect.any(Error) + ) + } finally { + warn.mockRestore() + } + }) + + it('waits for exit on background terminal handles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + const waiting = runtime.waitForTerminal(handle, { condition: 'exit', timeoutMs: 1000 }) + runtime.onPtyExit('pty-bg', 7) + + await expect(waiting).resolves.toMatchObject({ + handle, + condition: 'exit', + status: 'exited', + exitCode: 7 + }) + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ + status: 'exited' + }) + }) + + it('splits text and enter writes for background terminal handles', async () => { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + await runtime.sendTerminal(handle, { text: 'continue', enter: true }) + + expect(writes).toEqual(['continue', '\r']) + }) + + it('reveals a background terminal session when focusing its handle', async () => { + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'worker' + }) + + await expect(runtime.focusTerminal(handle)).resolves.toMatchObject({ + handle, + tabId: 'tab-adopted', + worktreeId: TEST_WORKTREE_ID + }) + expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { + ptyId: 'pty-bg', + title: 'worker' + }) + }) + + it('rejects focusing an exited background terminal session', async () => { + const revealTerminalSession = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + revealTerminalSession.mockClear() + runtime.onPtyExit('pty-bg', 0) + + await expect(runtime.focusTerminal(handle)).rejects.toThrow('terminal_exited') + expect(revealTerminalSession).not.toHaveBeenCalled() + }) + + it('renames background terminal handles without requiring a visible tab', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + await expect(runtime.renameTerminal(handle, 'Worker')).resolves.toMatchObject({ + handle, + tabId: 'pty:pty-bg', + title: 'Worker' + }) + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + title: 'Worker' + }) + }) + + it('keeps a background terminal handle stable while reveal adoption is racing', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'worker' + }) + + await runtime.focusTerminal(handle) + ;(runtime as unknown as { handleByPtyId: Map }).handleByPtyId.delete('pty-bg') + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + handle, + ptyId: 'pty-bg' + }) + }) + it('waits for terminal exit and resolves with the exit status', async () => { const runtime = new OrcaRuntimeService(store) @@ -471,32 +793,44 @@ describe('OrcaRuntimeService', () => { it('delivers pending orchestration messages to an already-idle agent', async () => { vi.useFakeTimers() - const runtime = new OrcaRuntimeService(store) - const db = new OrchestrationDb(':memory:') - const write = vi.fn().mockReturnValue(true) - runtime.setOrchestrationDb(db) - runtime.setPtyController({ - write, - kill: vi.fn(), - getForegroundProcess: async () => null - }) - syncSinglePty(runtime) + try { + const runtime = new OrcaRuntimeService(store) + const db = new OrchestrationDb(':memory:') + const write = vi.fn().mockReturnValue(true) + runtime.setOrchestrationDb(db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) - const [terminal] = (await runtime.listTerminals()).terminals - runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) - runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) - db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' }) + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' }) - runtime.deliverPendingMessagesForHandle(terminal.handle) + runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello')) - // Why: markAsRead is deferred until the 500ms delayed Enter is confirmed, - // so we must advance timers past the split-write delay. - await vi.advanceTimersByTimeAsync(500) - expect(write).toHaveBeenCalledWith('pty-1', '\r') - expect(db.getUnreadMessages(terminal.handle)).toHaveLength(0) - db.close() - vi.useRealTimers() + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello')) + // Why: the split Enter write lands after the 500ms delay, so we advance + // past it before asserting on delivered_at. + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + + // Why: design doc §3.2 splits delivered vs. read — push-on-idle stamps + // `delivered_at` but must *not* flip `read`, since only the check caller + // (the agent) is authorized to consume messages from its queue. The + // injected banner is a courtesy; the rows stay unread so the agent can + // still observe them via `check` and resolve the consumption race. + const unread = db.getUnreadMessages(terminal.handle) + expect(unread).toHaveLength(1) + expect(unread[0].read).toBe(0) + expect(unread[0].delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } }) it('adopts preallocated ORCA_TERMINAL_HANDLE as a valid runtime handle', async () => { @@ -557,6 +891,36 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['after unavailable']) }) + it('keeps runtime-created PTY handles valid after graph unavailable', async () => { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + runtime.markGraphUnavailable(1) + runtime.onPtyData('pty-bg', 'after unavailable\n', 100) + + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ + handle, + tail: ['after unavailable'] + }) + await expect(runtime.sendTerminal(handle, { text: 'still writable' })).resolves.toMatchObject({ + handle, + accepted: true + }) + expect(writes).toEqual(['still writable']) + }) + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { const runtime = new OrcaRuntimeService(store) const db = new OrchestrationDb(':memory:') @@ -751,7 +1115,11 @@ describe('OrcaRuntimeService', () => { repo: 'repo', path: '/tmp/worktree-a', branch: 'feature/foo', + displayName: 'foo', linkedIssue: 123, + linkedPR: null, + isPinned: false, + status: 'active', unread: false, liveTerminalCount: 1, hasAttachedPty: true, @@ -764,6 +1132,46 @@ describe('OrcaRuntimeService', () => { }) }) + it('clears stale working status after the agent exits and the shell takes over the title', async () => { + // Why: regression test for issue #1437 — the mobile worktree-list spinner + // kept playing forever because lastAgentStatus was sticky on 'working' + // once an agent exited without emitting an idle/agent-shaped final OSC + // title. worktree.ps must recompute from the live OSC title each call. + const runtime = new OrcaRuntimeService(store) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Codex working', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const working = await runtime.getWorktreePs() + expect(working.worktrees[0].status).toBe('working') + + // Agent exits, shell title takes over — desktop's getWorktreeStatus would + // immediately flip back to 'active'. Mobile must do the same. + runtime.onPtyData('pty-1', '\x1b]0;bash\x07', 200) + const afterExit = await runtime.getWorktreePs() + expect(afterExit.worktrees[0].status).toBe('active') + }) + it('fails terminal stop closed while the renderer graph is reloading', async () => { const runtime = new OrcaRuntimeService(store) let killed = false @@ -914,7 +1322,10 @@ describe('OrcaRuntimeService', () => { splitTerminal: vi.fn(), renameTerminal: vi.fn(), focusTerminal: vi.fn(), - closeTerminal: vi.fn() + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() }) runtime.attachWindow(1) @@ -978,7 +1389,7 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) }) - it('skips setup hooks for CLI-created worktrees by default', async () => { + it('passes setup payloads through when explicitly activating CLI-created worktrees', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() runtime.setNotifier({ @@ -989,7 +1400,74 @@ describe('OrcaRuntimeService', () => { splitTerminal: vi.fn(), renameTerminal: vi.fn(), focusTerminal: vi.fn(), - closeTerminal: vi.fn() + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-activate') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-activate') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-activate' + } + }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-hook-activate', + head: 'def', + branch: 'runtime-hook-activate', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-activate', + runHooks: true, + activate: true + }) + + expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) + }) + + it('follows normal setup policy for CLI-created worktrees without activating them', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-created-worktree' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-primary' }) + .mockResolvedValueOnce({ id: 'pty-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() }) runtime.attachWindow(1) @@ -1000,7 +1478,15 @@ describe('OrcaRuntimeService', () => { setup: 'pnpm worktree:setup' } }) - vi.mocked(listWorktrees).mockResolvedValueOnce([ + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ { path: '/tmp/workspaces/runtime-hook-skip', head: 'def', @@ -1015,7 +1501,11 @@ describe('OrcaRuntimeService', () => { name: 'runtime-hook-skip' }) - expect(createSetupRunnerScript).not.toHaveBeenCalled() + expect(createSetupRunnerScript).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), + '/tmp/workspaces/runtime-hook-skip', + 'pnpm worktree:setup' + ) expect(runHook).not.toHaveBeenCalled() expect(result).toEqual({ worktree: expect.objectContaining({ @@ -1023,12 +1513,250 @@ describe('OrcaRuntimeService', () => { path: '/tmp/workspaces/runtime-hook-skip', branch: 'runtime-hook-skip' }), - warning: - 'orca.yaml setup hook skipped for /tmp/workspaces/runtime-hook-skip; pass --run-hooks to run it.' + setup: { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' + } + } }) + expect(activateWorktree).not.toHaveBeenCalled() + expect(spawn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-hook-skip', + command: undefined, + worktreeId: result.worktree.id + }) + ) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-hook-skip', + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' + }, + worktreeId: result.worktree.id + }) + ) + expect(revealTerminalSession).toHaveBeenLastCalledWith(result.worktree.id, { + ptyId: 'pty-setup', + title: 'Setup', + activate: false + }) + }) + + it('creates the first terminal for CLI-created worktrees without activating them', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-created-worktree' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-created-worktree' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-initial-terminal') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-initial-terminal') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-initial-terminal', + head: 'def', + branch: 'runtime-initial-terminal', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-initial-terminal' + }) + + expect(activateWorktree).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-initial-terminal', + worktreeId: result.worktree.id, + preAllocatedHandle: expect.stringMatching(/^term_/) + }) + ) + expect(revealTerminalSession).toHaveBeenCalledWith(result.worktree.id, { + ptyId: 'pty-created-worktree', + title: null, + activate: false + }) + }) + + it('keeps CLI-created worktrees successful when initial terminal creation fails', async () => { + const runtime = new OrcaRuntimeService(store) + const spawn = vi.fn().mockRejectedValue(new Error('pty unavailable')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-terminal-fail') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-terminal-fail') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-terminal-fail', + head: 'def', + branch: 'runtime-terminal-fail', + isBare: false, + isMainWorktree: false + } + ]) + + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-terminal-fail' + }) + ).resolves.toMatchObject({ + worktree: expect.objectContaining({ + path: '/tmp/workspaces/runtime-terminal-fail' + }), + warning: + 'Failed to create the initial terminal for /tmp/workspaces/runtime-terminal-fail: pty unavailable' + }) + expect(spawn).toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[worktree-create] Failed to create the initial terminal for /tmp/workspaces/runtime-terminal-fail: pty unavailable' + ) + } finally { + warn.mockRestore() + } + }) + + it('activates CLI-created worktrees only when explicitly requested', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-activate') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-activate') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-activate', + head: 'def', + branch: 'runtime-activate', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-activate', + activate: true + }) + expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) }) + it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => { + // Why: parity with createLocalWorktree / createRemoteWorktree. Without + // createdAt, ambient PTY bumps in OTHER worktrees during the few seconds + // after creation can push the new worktree below them in Recent sort. + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-grace') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-grace') + vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: {} }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-grace', + head: 'def', + branch: 'runtime-grace', + isBare: false, + isMainWorktree: false + } + ]) + + const before = Date.now() + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-grace' + }) + const after = Date.now() + + expect(result.worktree.createdAt).toBeDefined() + expect(result.worktree.createdAt).toBeGreaterThanOrEqual(before) + expect(result.worktree.createdAt).toBeLessThanOrEqual(after) + // Both fields must be stamped from the same `now` so the grace-window + // math (max(lastActivityAt, createdAt + GRACE_MS)) is well-defined. + expect(result.worktree.createdAt).toBe(result.worktree.lastActivityAt) + }) + it('skips archive hooks for CLI worktree removal by default', async () => { const runtime = new OrcaRuntimeService(store) vi.mocked(getEffectiveHooks).mockReturnValue({ @@ -1067,6 +1795,46 @@ describe('OrcaRuntimeService', () => { expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) }) + it('clears optimistic reconcile tokens when a CLI worktree removal succeeds', async () => { + const runtime = new OrcaRuntimeService(store) + const worktreeBaseStatus = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + worktreeBaseStatus, + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + vi.mocked(removeWorktree).mockResolvedValue(undefined) + + const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + await runtime.reconcileWorktreeBaseStatus({ + repoId: TEST_REPO_ID, + repoPath: TEST_REPO_PATH, + worktreeId: TEST_WORKTREE_ID, + base: { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }, + branchName: 'feature', + createdBaseSha: 'created-sha', + token, + fetchPromise: Promise.resolve({ ok: true }) + }) + + expect(worktreeBaseStatus).not.toHaveBeenCalled() + }) + it('invalidates the filesystem-auth cache after CLI worktree creation', async () => { // Reproduces: CLI-created worktrees fail with "Access denied: unknown // repository or worktree path" because the filesystem-auth cache was @@ -1080,7 +1848,10 @@ describe('OrcaRuntimeService', () => { splitTerminal: vi.fn(), renameTerminal: vi.fn(), focusTerminal: vi.fn(), - closeTerminal: vi.fn() + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() }) computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-worktree') @@ -1138,6 +1909,7 @@ describe('OrcaRuntimeService', () => { return nextMeta }, removeWorktreeMeta: () => {}, + getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: 'C:\\workspaces', nestWorkspaces: false, @@ -1256,6 +2028,24 @@ describe('OrcaRuntimeService', () => { expect(captureStartMock).toHaveBeenCalledWith(undefined, 'page-2') }) + it('accepts focus on tab switch without altering bridge args (focus is main-side concern)', async () => { + const runtime = createRuntime() + const tabSwitchMock = vi.fn().mockResolvedValue({ + switched: 0, + browserPageId: 'page-1' + }) + + runtime.setAgentBrowserBridge({ tabSwitch: tabSwitchMock } as never) + + await expect(runtime.browserTabSwitch({ page: 'page-1', focus: true })).resolves.toEqual({ + switched: 0, + browserPageId: 'page-1' + }) + // Bridge is unchanged — focus is delivered to the renderer via IPC + // (notifyRendererBrowserPaneFocus), not threaded through bridge state. + expect(tabSwitchMock).toHaveBeenCalledWith(undefined, undefined, 'page-1') + }) + it('does not silently drop invalid explicit worktree selectors for page-targeted commands', async () => { vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) const runtime = createRuntime() @@ -1447,10 +2237,9 @@ describe('OrcaRuntimeService', () => { // The post-daemon provider's prefix-matching session must have been // shut down, proving the thunk resolved lazily at call time. - expect(postDaemonProvider.shutdown).toHaveBeenCalledWith( - `${TEST_WORKTREE_ID}@@aaaaaaaa`, - true - ) + expect(postDaemonProvider.shutdown).toHaveBeenCalledWith(`${TEST_WORKTREE_ID}@@aaaaaaaa`, { + immediate: true + }) // The pre-daemon provider must not have been consulted for the kill. expect(preDaemonProvider.shutdown).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3ba6281df65..01548ea00ca 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7,15 +7,28 @@ import { isShellProcess } from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' -import { gitExecFileAsync, gitExecFileSync } from '../git/runner' +import { gitExecFileAsync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { randomUUID } from 'crypto' import { join } from 'path' import { rm } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' -import type { CreateWorktreeResult, Repo } from '../../shared/types' +import type { + CreateWorktreeResult, + GlobalSettings, + Repo, + StatsSummary, + WorktreeBaseStatusEvent, + WorktreeRemoteBranchConflictEvent, + WorktreeStartupLaunch +} from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' +import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { + DESKTOP_PROTOCOL_VERSION, + MIN_COMPATIBLE_MOBILE_VERSION +} from '../../shared/protocol-version' import type { RuntimeGraphStatus, RuntimeRepoSearchRefs, @@ -32,6 +45,7 @@ import type { RuntimeTerminalWait, RuntimeTerminalWaitCondition, RuntimeWorktreePsSummary, + RuntimeWorktreeStatus, RuntimeTerminalShow, RuntimeTerminalSummary, RuntimeSyncedLeaf, @@ -47,9 +61,17 @@ import type { BrowserScrollResult, BrowserBackResult, BrowserReloadResult, + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileListResult, BrowserScreenshotResult, BrowserEvalResult, + BrowserTabCurrentResult, BrowserTabListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabSetProfileResult, + BrowserTabShowResult, BrowserTabSwitchResult, BrowserHoverResult, BrowserDragResult, @@ -75,7 +97,9 @@ import type { } from '../../shared/runtime-types' import { BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' +import { browserSessionRegistry } from '../browser/browser-session-registry' import { waitForTabRegistration } from '../ipc/browser' import { getPRForBranch } from '../github/client' import { @@ -84,10 +108,19 @@ import { getBranchConflictKind, isGitRepo, getRepoName, - searchBaseRefs + searchBaseRefs, + getRemoteDrift, + getRecentDriftSubjects } from '../git/repo' import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' -import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' +import { + createSetupRunnerScript, + getEffectiveHooks, + getEffectiveSetupRunPolicy, + hasHooksFile, + runHook, + shouldRunSetupForCreate +} from '../hooks' import { REPO_COLORS } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import type { Store } from '../persistence' @@ -105,8 +138,36 @@ import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { HeadlessEmulator } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' +import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IPtyProvider } from '../providers/types' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { CodexAccountService } from '../codex-accounts/service' +import type { RateLimitService } from '../rate-limits/service' +import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import type { RateLimitState } from '../../shared/rate-limit-types' + +type RuntimeAccountServices = { + claudeAccounts: ClaudeAccountService + codexAccounts: CodexAccountService + rateLimits: RateLimitService +} + +export type RemoteFetchResult = { ok: true } | { ok: false; errorKind: 'git_error' } + +export type RemoteTrackingBase = { + remote: string + branch: string + ref: string + base: string +} + +export type AccountsSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState +} type RuntimeStore = { getRepos: Store['getRepos'] @@ -117,13 +178,20 @@ type RuntimeStore = { getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] removeWorktreeMeta: Store['removeWorktreeMeta'] + getGitHubCache: Store['getGitHubCache'] + getWorkspaceSession?: Store['getWorkspaceSession'] getSettings(): { workspaceDir: string nestWorkspaces: boolean refreshLocalBaseRefOnWorktreeCreate: boolean branchPrefix: string branchPrefixCustom: string + mobileAutoRestoreFitMs?: number | null } + // Why: narrow to `unknown` return so test mocks can return void without + // a cast. The runtime never reads the return value — the persisted value + // is read back via getSettings() on the next access. + updateSettings?: (updates: Partial) => unknown } type RuntimeLeafRecord = RuntimeSyncedLeaf & { @@ -138,19 +206,81 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { tailLinesTotal: number preview: string lastAgentStatus: AgentStatus | null + // Why: the most recent OSC title observed on this leaf's PTY data. Used by + // worktree.ps so daemon-hosted terminals (no renderer pushing pane titles) + // still recompute working/idle from the live title each call instead of + // serving a stale `lastAgentStatus` after the agent process exits and the + // shell takes over the title — the bug behind issue #1437. + lastOscTitle: string | null +} + +type RuntimePtyWorktreeRecord = { + ptyId: string + worktreeId: string + connected: boolean + lastExitCode: number | null + lastAgentStatus: AgentStatus | null + lastOscTitle: string | null + title: string | null + lastOutputAt: number | null + tailBuffer: string[] + tailPartialLine: string + tailTruncated: boolean + tailLinesTotal: number + preview: string +} + +type RuntimeHeadlessTerminal = { + emulator: HeadlessEmulator + writeChain: Promise } type RuntimePtyController = { + spawn?(opts: { + cols: number + rows: number + cwd?: string + command?: string + env?: Record + connectionId?: string | null + worktreeId?: string + preAllocatedHandle?: string + }): Promise<{ id: string }> write(ptyId: string, data: string): boolean kill(ptyId: string): boolean getForegroundProcess(ptyId: string): Promise + resize?(ptyId: string, cols: number, rows: number): boolean + listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + serializeBuffer?( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + // Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip + // hydration when no renderer is authoritative for this PTY. See + // docs/mobile-prefer-renderer-scrollback.md. + hasRendererSerializer?(ptyId: string): boolean + getSize?(ptyId: string): { cols: number; rows: number } | null } type RuntimeNotifier = { worktreesChanged(repoId: string): void + worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void + worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void - activateWorktree(repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup']): void + activateWorktree( + repoId: string, + worktreeId: string, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch + ): void createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void + revealTerminalSession?( + worktreeId: string, + opts: { ptyId: string; title?: string | null; activate?: boolean } + ): + | Promise<{ tabId: string; title?: string | null }> + | { tabId: string; title?: string | null } + | void splitTerminal( tabId: string, paneRuntimeId: number, @@ -159,6 +289,20 @@ type RuntimeNotifier = { renameTerminal(tabId: string, title: string | null): void focusTerminal(tabId: string, worktreeId: string): void closeTerminal(tabId: string, paneRuntimeId?: number): void + sleepWorktree(worktreeId: string): void + terminalFitOverrideChanged( + ptyId: string, + mode: 'mobile-fit' | 'desktop-fit', + cols: number, + rows: number + ): void + // Why: presence-based lock signal — desktop renderer mounts the lock + // banner when `driver.kind === 'mobile'` and unmounts otherwise. The + // structured payload (vs a `locked: boolean`) carries the active mobile + // actor's clientId so the renderer can disambiguate multi-phone scenarios + // and so a future write coordinator can use the same signal as scheduling + // input. See docs/mobile-presence-lock.md. + terminalDriverChanged(ptyId: string, driver: DriverState): void } type TerminalHandleRecord = { @@ -220,6 +364,60 @@ type ResolvedWorktreeCache = { worktrees: ResolvedWorktree[] } +export type MobileNotificationEvent = { + source: 'agent-task-complete' | 'terminal-bell' | 'test' + title: string + body: string + worktreeId?: string +} + +// Why: presence-based driver state for the mobile-presence lock. Exactly one +// driver per PTY at any moment. See docs/mobile-presence-lock.md. +// - `idle`: no mobile subscribers; desktop input flows freely +// - `desktop`: at least one mobile client subscribed but desktop reclaimed +// (or all mobile clients are passive `desktop`-mode watchers); desktop +// input flows freely +// - `mobile{clientId}`: a mobile client is the active driver; desktop +// input/resize are dropped server-side and the lock banner is mounted. +// `clientId` is the most recent mobile actor for this PTY. +export type DriverState = + | { kind: 'idle' } + | { kind: 'desktop' } + | { kind: 'mobile'; clientId: string } + +// Why: per-PTY layout target — what the PTY *should* be at right now. +// `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive +// watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at +// `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed. +// See docs/mobile-terminal-layout-state-machine.md. +export type PtyLayoutTarget = + | { kind: 'desktop'; cols: number; rows: number } + | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } + +// Why: authoritative layout state with monotonic seq. Bumped on every +// applyLayout success; emitted on mobile subscribe-stream events so clients +// drop stale events that arrive after a newer transition. +export type PtyLayoutState = PtyLayoutTarget & { + seq: number + appliedAt: number +} + +// Why: applyLayout result discriminator. Callers (especially RPC handlers) +// need to distinguish "shipped a new state at seq N" from "no-op — caller +// should not claim a seq it didn't produce." `pty-exited` is terminal; +// `resize-failed` is transient and the caller may retry. +export type ApplyLayoutResult = + | { ok: true; state: PtyLayoutState } + | { ok: false; reason: 'pty-exited' | 'resize-failed' } + +type LayoutQueueEntry = { + running: Promise | null + pending: { + target: PtyLayoutTarget + waiters: ((r: ApplyLayoutResult) => void)[] + }[] +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -242,7 +440,224 @@ export class OrcaRuntimeService { private agentDetector: AgentDetector | null = null private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() + // Why: mobile clients subscribe to terminal output via terminal.subscribe. + // These listeners fire on every onPtyData call, enabling real-time streaming + // without polling. Keyed by ptyId for O(1) lookup per data event. + private dataListeners = new Map void>>() + // Why: mobile clients need to know when the desktop restores a terminal + // from mobile-fit so they can update their UI. These listeners are + // invoked from resizeForClient and onClientDisconnected/onPtyExit. + private fitOverrideListeners = new Map< + string, + Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> + >() + private subscriptionCleanups = new Map void>() + // Why: index of subscriptionIds by per-WebSocket connectionId so the + // server can sweep all subscriptions for a closing socket without + // touching subscriptions on other live sockets that share the same + // deviceToken (multi-screen mobile). + private subscriptionsByConnection = new Map>() + private subscriptionConnectionByEntry = new Map() + // Why: mobile clients subscribe to desktop notifications via + // notifications.subscribe. This set enables fan-out — each connected + // mobile client gets its own listener, and dispatchMobileNotification + // iterates them all. Listeners are cleaned up via subscriptionCleanups. + private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() + private ptysById = new Map() + private headlessTerminals = new Map() + // Why: per-PTY hydration state guards against double-hydration. Keys: + // 'pending' → maybeHydrateHeadlessFromRenderer is in flight + // 'done' → hydration completed (success or skip); never run again + // Absent → hydration has not been considered yet for this PTY. + // See docs/mobile-prefer-renderer-scrollback.md. + private headlessHydrationState = new Map() + // Why: mobile-fit overrides are keyed by ptyId (not terminal handle) because + // handles can be reissued while the PTY identity is stable. In-memory only — + // a stale phone override should not survive an app restart. + private terminalFitOverrides = new Map< + string, + { + mode: 'mobile-fit' + cols: number + rows: number + previousCols: number | null + previousRows: number | null + updatedAt: number + clientId: string + } + >() + + // Why: server-authoritative display mode per terminal. 'auto' (default) + // means phone-fit when mobile subscribes, desktop otherwise. 'desktop' + // locks to no-resize regardless of subscriber state. The third historical + // value ('phone' = sticky phone-fit after unsubscribe) was removed since + // the toggle UI never produced it and nothing in product depended on it. + // In-memory only — modes reset on restart. + private mobileDisplayModes = new Map() + + // Why: tracks active mobile subscribers per PTY so the runtime can restore + // desktop dimensions on unsubscribe and prevent orphaned overrides during + // rapid tab switches. Keyed by ptyId → inner map of clientId → subscriber. + // The two-level map preserves multi-mobile soundness: phone B subscribing + // does not silently overwrite phone A's record. See + // docs/mobile-presence-lock.md "Multi-mobile subscriber model". + // subscribedAt drives "earliest-by-subscribe-time" restore-target selection + // (only among subscribers with non-null previousCols/Rows; desktop-mode + // joins carry null and are skipped). lastActedAt drives "most-recent + // actor's viewport wins" for active phone-fit dims. + private mobileSubscribers = new Map< + string, + Map< + string, + { + clientId: string + viewport: { cols: number; rows: number } | null + wasResizedToPhone: boolean + previousCols: number | null + previousRows: number | null + subscribedAt: number + lastActedAt: number + } + > + >() + + // Why: per-PTY driver state. The "driver" is whoever currently owns the + // input/resize floor. While `kind === 'mobile'` the desktop renderer drops + // xterm.onData/onResize and shows the lock banner; `terminal.send` / + // `pty:write` and `pty:resize` IPC handlers also drop desktop-side calls + // server-side as defense-in-depth. The `clientId` carried on the mobile + // variant is the most recent mobile actor — used by + // `applyMobileDisplayMode` to pick the active phone-fit viewport. See + // docs/mobile-presence-lock.md. + private currentDriver = new Map() + + // Why: resubscribe-grace window. When the last mobile subscriber for a + // PTY unsubscribes, we hold the driver=mobile{clientId} state and the + // inner-map record open for ~250ms. If the same (ptyId, clientId) + // re-subscribes inside the window — typically because the mobile app + // tore down the stream to reconfigure (rare with the new + // updateMobileViewport path, but still possible on reconnects, network + // hiccups, or older client builds) — we cancel the deferred idle and + // restore-timer so the desktop banner doesn't flash and the new + // subscriber doesn't capture an already-phone-fitted PTY size as its + // restore baseline. Keyed by ptyId; carries the timer plus the snapshot + // of the leaving subscriber so we can re-insert it on cancel. See + // docs/mobile-presence-lock.md. + private pendingSoftLeavers = new Map< + string, + { + clientId: string + timer: ReturnType + record: { + clientId: string + viewport: { cols: number; rows: number } | null + wasResizedToPhone: boolean + previousCols: number | null + previousRows: number | null + subscribedAt: number + lastActedAt: number + } + } + >() + + // Why: tracks the last PTY size set by the desktop renderer (via pty:resize + // IPC). Unlike ptySizes (which is overwritten by server-side phone-fit + // resizes), this map preserves the actual pane geometry. Used as the + // preferred source for previousCols so desktop restore uses the correct + // split-pane width instead of a stale full-width value. + private lastRendererSizes = new Map() + + // Why: when a desktop-fit override change fires, the desktop renderer's + // re-render cascade (triggered by setOverrideTick) runs safeFit on ALL + // panes — not just the affected one. Background tab panes get measured at + // full-width (214) instead of their correct split width (105). The stale + // pty:resize IPCs overwrite both the actual PTY size and lastRendererSizes. + // This global window suppresses ALL pty:resize for 200ms after any + // desktop-fit notification. The server has already set the correct PTY + // size via ptyController.resize(), so desktop renderer resizes during + // this window are redundant (for the restored pane) or wrong (collateral). + private resizeSuppressedUntil = 0 + + // Why: delays PTY restore by 300ms after mobile unsubscribe so rapid tab + // switches don't cause unnecessary resize thrashing. Keyed by clientId + // Why: keyed by ptyId so each PTY gets its own independent restore timer. + // The old clientId-keyed design lost timers when two PTYs were unsubscribed + // back-to-back (only the last timer survived). + private pendingRestoreTimers = new Map< + string, + { timer: ReturnType; clientId: string } + >() + + // Why: inline resize events replace the unsubscribe→resubscribe pattern. + // Listeners are notified when mode changes or desktop restores, allowing + // the subscribe stream to emit a 'resized' event with fresh scrollback. + // `seq` is the layout state-machine sequence number bumped on every + // applyLayout success; mobile clients use it to drop stale events that + // arrive after a newer transition. See docs/mobile-terminal-layout-state-machine.md. + private resizeListeners = new Map< + string, + Set< + (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void + > + >() + + // Why: per-PTY layout state machine. `applyLayout` is the sole writer of + // `layouts`, `terminalFitOverrides`, and `ptyController.resize`; every + // trigger method routes through `enqueueLayout`. The monotonic `seq` is + // emitted on the mobile subscribe stream so clients can drop stale events. + // See docs/mobile-terminal-layout-state-machine.md. + private layouts = new Map() + + // Why: per-PTY async serialization queue for applyLayout. Without + // serialization, two concurrent triggers can interleave around the + // ptyController.resize await and bump seq in the wrong order, defeating + // seq-as-truth. Coalesces same-kind same-owner viewport ticks so the + // keyboard-show/hide animation doesn't queue 10+ resizes; mode flips, + // take-floor, and different-owner targets always append (preserves + // multi-mobile fairness). See docs/mobile-terminal-layout-state-machine.md + // "enqueueLayout coalescing". + private layoutQueues = new Map() + + // Why: gate so enqueueLayout's "no layouts entry" short-circuit doesn't + // fire on the very first transition for a PTY (where the entry doesn't + // exist yet *because* we're about to create it). `handleMobileSubscribe` + // adds the ptyId before calling enqueueLayout and removes it after the + // call resolves. + private freshSubscribeGuard = new Set() + + private stats: StatsCollector | null = null + // Why (§3.3 + §7.1): the renderer-create path and coordinator + // `probeWorktreeDrift` share this cache so a create that already fetched + // `origin` within the last 30s does not re-fetch during dispatch, and + // vice-versa. Keyed by `::` so multi-remote repos (even + // though v1 only uses `origin`) don't cross-contaminate. The in-flight Map + // also provides serialization — two concurrent callers share a single + // underlying `git fetch`. Lifecycle rules are enforced in + // `fetchRemoteWithCache` and MUST NOT be duplicated elsewhere: + // - entry inserted BEFORE await, + // - `.finally()` removes the entry on BOTH success and rejection, + // - timestamp written ONLY on success (rejection must not make the + // 30s freshness cache lie). + // A literal "insert before await / read-back after await" without these + // three rules wedges all future creates on the same repo after a single + // DNS hiccup until process restart (see §3.3 Lifecycle). + private fetchInflight = new Map>() + private fetchLastCompletedAt = new Map() + // Why: `getCanonicalFetchKey` is awaited from every freshness probe and + // every getOrStartRemoteFetch call. Without memoization the warm-cache hot + // path spawns a `git rev-parse --git-common-dir` subprocess per touch + // (twice in createLocalWorktree). Cache by `::` so the + // canonical key is resolved at most once per repo+remote in the process. + private canonicalFetchKeyCache = new Map() + private optimisticReconcileTokens = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null + private accountServices: RuntimeAccountServices | null = null constructor( store: RuntimeStore | null = null, @@ -251,6 +666,7 @@ export class OrcaRuntimeService { ) { this.store = store if (stats) { + this.stats = stats this.agentDetector = new AgentDetector(stats) } // Why: the daemon adapter is installed via `setLocalPtyProvider()` during @@ -266,6 +682,10 @@ export class OrcaRuntimeService { return this.getLocalProviderFn ? this.getLocalProviderFn() : null } + getStatsSummary(): StatsSummary | null { + return this.stats?.getSummary() ?? null + } + // Why: lazy initialization — the DB path depends on Electron's userData // which may not be finalized until after app.ready. Also allows unit tests // to inject an in-memory DB without touching the filesystem. @@ -297,7 +717,9 @@ export class OrcaRuntimeService { graphStatus: this.graphStatus, authoritativeWindowId: this.authoritativeWindowId, liveTabCount: this.tabs.size, - liveLeafCount: this.leaves.size + liveLeafCount: this.leaves.size, + protocolVersion: DESKTOP_PROTOCOL_VERSION, + minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION } } @@ -365,9 +787,18 @@ export class OrcaRuntimeService { tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false, tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0, preview: existing?.ptyId === ptyId ? existing.preview : '', - lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null + lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null, + lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null }) + if (leaf.ptyId) { + this.recordPtyWorktree(leaf.ptyId, leaf.worktreeId, { + connected: true, + lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, + preview: existing?.ptyId === leaf.ptyId ? existing.preview : '' + }) + } + if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { this.invalidateLeafHandle(leafKey) } @@ -446,6 +877,10 @@ export class OrcaRuntimeService { } onPtySpawned(ptyId: string): void { + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.connected = true + } for (const leaf of this.leaves.values()) { if (leaf.ptyId === ptyId) { leaf.connected = true @@ -455,10 +890,23 @@ export class OrcaRuntimeService { } } + registerPty(ptyId: string, worktreeId: string): void { + this.recordPtyWorktree(ptyId, worktreeId, { connected: true }) + } + onPtyData(ptyId: string, data: string, at: number): void { // Agent detection runs on raw data before leaf processing, since the // tail buffer logic normalizes away the OSC sequences we need. this.agentDetector?.onData(ptyId, data, at) + // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer + // MUST run before trackHeadlessTerminalData so the eager-state pattern + // (set headlessTerminals + writeChain head = seedPromise) is in place + // before the live byte's chain link is queued. Without this ordering, + // trackHeadlessTerminalData would lazy-create a fresh state at PTY dims + // that the later seed-resolve would overwrite, dropping the live byte. + // See docs/mobile-prefer-renderer-scrollback.md. + this.maybeHydrateHeadlessFromRenderer(ptyId) + this.trackHeadlessTerminalData(ptyId, data) // Why: extract OSC title from raw PTY data before tail-buffer processing // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) @@ -467,10 +915,35 @@ export class OrcaRuntimeService { const oscTitle = extractLastOscTitle(data) const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.connected = true + pty.lastOutputAt = at + const nextTail = appendToTailBuffer(pty.tailBuffer, pty.tailPartialLine, data) + pty.tailBuffer = nextTail.lines + pty.tailPartialLine = nextTail.partialLine + pty.tailTruncated = pty.tailTruncated || nextTail.truncated + pty.tailLinesTotal += nextTail.newCompleteLines + pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) + if (oscTitle !== null) { + const prevStatus = pty.lastAgentStatus + pty.lastOscTitle = oscTitle + pty.lastAgentStatus = agentStatus + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolvePtyTuiIdleWaiters(pty, ptyId) + } + } + } + for (const leaf of this.leaves.values()) { if (leaf.ptyId !== ptyId) { continue } + this.recordPtyWorktree(ptyId, leaf.worktreeId, { + connected: true, + lastOutputAt: pty?.lastOutputAt ?? at, + preview: pty?.preview ?? leaf.preview + }) leaf.connected = true leaf.writable = this.graphStatus === 'ready' leaf.lastOutputAt = at @@ -481,8 +954,21 @@ export class OrcaRuntimeService { leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) - if (agentStatus !== null) { + if (oscTitle !== null) { + // Why: keep the latest OSC title on the leaf so worktree.ps can + // recompute status from the live title each call. Without this, + // daemon-hosted terminals (no renderer pushing pane titles) had no + // way to clear a stale 'working' status after the agent exited and + // the shell took over the title — the stuck-spinner bug in #1437. + leaf.lastOscTitle = oscTitle const prevStatus = leaf.lastAgentStatus + // Why: when a new OSC title doesn't classify as an agent state (e.g. + // bare shell title after the agent exits), clear lastAgentStatus so + // it is no longer sticky. Tui-idle waiters that needed the previous + // 'idle' transition were already resolved at the moment of the + // transition below; only fresh waiters registered after the agent + // exits would observe the cleared value, and they correctly fall + // back to title-based detection / polling. leaf.lastAgentStatus = agentStatus // Why: resolve tui-idle on any transition TO idle (not just working→idle). // Claude Code may skip "working" entirely on fast tasks, going null→idle, @@ -496,10 +982,774 @@ export class OrcaRuntimeService { } } } + + const listeners = this.dataListeners.get(ptyId) + if (listeners) { + for (const listener of listeners) { + listener(data) + } + } + } + + subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void { + let listeners = this.dataListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.dataListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.dataListeners.delete(ptyId) + } + } + } + + subscribeToFitOverrideChanges( + ptyId: string, + listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void + ): () => void { + let listeners = this.fitOverrideListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.fitOverrideListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.fitOverrideListeners.delete(ptyId) + } + } + } + + private notifyFitOverrideListeners( + ptyId: string, + mode: 'mobile-fit' | 'desktop-fit', + cols: number, + rows: number + ): void { + const listeners = this.fitOverrideListeners.get(ptyId) + if (!listeners) { + return + } + for (const listener of listeners) { + listener({ mode, cols, rows }) + } + } + + serializeTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number } | null> { + return this.serializeTerminalBufferFromAvailableState(ptyId, opts) + } + + getTerminalSize(ptyId: string): { cols: number; rows: number } | null { + return this.ptyController?.getSize?.(ptyId) ?? null + } + + // Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch + // start with a fresh headless emulator that has zero scrollback, even though + // the daemon's on-disk checkpoint and the desktop xterm both contain the + // full prior history. Without this hydration, mobile subscribers see only + // the bare current prompt because serializeHeadlessTerminalBuffer always + // wins over the renderer-path fallback. Seeding the emulator with the + // adapter's snapshot/cold-restore data makes mobile and desktop agree on + // what scrollback is available. + seedHeadlessTerminal(ptyId: string, data: string, size?: { cols: number; rows: number }): void { + if (!data) { + return + } + const existing = this.headlessTerminals.get(ptyId) + if (existing) { + // Why: emulator already has live data — re-seeding would duplicate + // every byte. The seed is only valid when the emulator is fresh. + return + } + const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state: RuntimeHeadlessTerminal = { + emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + writeChain: Promise.resolve() + } + this.headlessTerminals.set(ptyId, state) + state.writeChain = state.writeChain + .then(() => state.emulator.write(data)) + .catch(() => { + // Seeding is best-effort; live data will continue to populate the + // emulator even if the snapshot replay fails. + }) + } + + // Why: hydrate the runtime headless emulator from the desktop renderer's + // xterm buffer on the first onPtyData byte after a PTY is taken over by a + // pane. Eager-state pattern matches seedHeadlessTerminal: headlessTerminals + // is populated synchronously so concurrent live writes from + // trackHeadlessTerminalData chain after the seed via the same writeChain. + // See docs/mobile-prefer-renderer-scrollback.md. + private maybeHydrateHeadlessFromRenderer(ptyId: string): void { + if (this.headlessHydrationState.has(ptyId)) { + return + } + if (this.headlessTerminals.has(ptyId)) { + // Daemon-snapshot seed already populated the emulator — skip hydration. + this.headlessHydrationState.set(ptyId, 'done') + return + } + const controller = this.ptyController + if (!controller?.serializeBuffer || !controller.hasRendererSerializer) { + return + } + if (!controller.hasRendererSerializer(ptyId)) { + // Renderer hasn't registered yet (or never will). Live writes lazy- + // create the state via trackHeadlessTerminalData on this same tick. + return + } + + this.headlessHydrationState.set(ptyId, 'pending') + const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state: RuntimeHeadlessTerminal = { + emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + writeChain: Promise.resolve() + } + this.headlessTerminals.set(ptyId, state) + + // Why: append the seed work to writeChain so live writes queued by + // trackHeadlessTerminalData (after this method returns synchronously) + // execute AFTER the seed-write resolves. If we awaited inline before + // setting headlessTerminals, the live byte would lazy-create a separate + // state and the seed-resolve would overwrite it, dropping live bytes. + state.writeChain = state.writeChain.then(async () => { + try { + const rendered = await controller.serializeBuffer!(ptyId, { + scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, + altScreenForcesZeroRows: true + }) + if (!rendered || rendered.data.length === 0) { + return + } + // Resize to renderer's dims so the seed reflows correctly into the + // emulator's grid, then resize back to PTY dims (if known) so live + // writes use the correct cell layout. + if (rendered.cols !== dims.cols || rendered.rows !== dims.rows) { + state.emulator.resize(rendered.cols, rendered.rows) + } + await state.emulator.write(rendered.data) + const ptyDims = this.getTerminalSize(ptyId) + if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { + state.emulator.resize(ptyDims.cols, ptyDims.rows) + } + if (rendered.lastTitle) { + this.applySeededAgentStatus(ptyId, rendered.lastTitle) + } + } catch { + // Hydration is best-effort. Live writes continue via the same + // writeChain that this catch-arm leaves intact. + } finally { + this.headlessHydrationState.set(ptyId, 'done') + } + }) + } + + // Why: seed-derived agent status reflects historical state. Orchestration + // waiters (resolveTuiIdleWaiters, deliverPendingMessages) must only react + // to LIVE transitions, so this helper writes leaf.lastAgentStatus only and + // never resolves waiters. detectAgentStatusFromTitle wrap mirrors the live + // path so seeded and live values are the same union member, keeping + // downstream `=== 'idle'` checks correct. + private applySeededAgentStatus(ptyId: string, title: string): void { + if (!title) { + return + } + const status = detectAgentStatusFromTitle(title) + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === ptyId) { + // Why: seed lastOscTitle even when the seeded title doesn't classify + // as an agent state, so worktree.ps recomputes status from the live + // title rather than treating the leaf as agentless. + leaf.lastOscTitle = title + if (status !== null) { + leaf.lastAgentStatus = status + } + } + } + } + + private trackHeadlessTerminalData(ptyId: string, data: string): void { + const state = this.getOrCreateHeadlessTerminal(ptyId) + state.writeChain = state.writeChain + .then(() => state.emulator.write(data)) + .catch(() => { + // Best-effort state tracking; live streaming must continue even if + // xterm rejects a malformed or raced write during shutdown. + }) + } + + private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { + const existing = this.headlessTerminals.get(ptyId) + if (existing) { + return existing + } + const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state: RuntimeHeadlessTerminal = { + emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }), + writeChain: Promise.resolve() + } + this.headlessTerminals.set(ptyId, state) + return state + } + + private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { + this.headlessTerminals.get(ptyId)?.emulator.resize(cols, rows) + } + + private async serializeTerminalBufferFromAvailableState( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number } | null> { + const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) + if (headlessSnapshot) { + return headlessSnapshot + } + + let rendererSnapshot: { + data: string + cols: number + rows: number + lastTitle?: string + } | null = null + try { + // Why: read-fallback wants visible alt-screen content (e.g. an active + // TUI like vim) so altScreenForcesZeroRows is FALSE here. Hydration is + // the only path that suppresses alt-screen scrollback. See + // docs/mobile-prefer-renderer-scrollback.md. + rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, { + scrollbackRows: opts.scrollbackRows, + altScreenForcesZeroRows: false + }) ?? Promise.resolve(null)) + } catch { + // Why: mobile scrollback should not depend on a mounted renderer pane. + // If renderer serialization races reload/unmount, the runtime snapshot + // below can still preserve colored terminal state. + } + if (rendererSnapshot && rendererSnapshot.data.length > 0) { + return rendererSnapshot + } + return rendererSnapshot + } + + private async serializeHeadlessTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number } | null> { + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return null + } + await state.writeChain + // Why: when an alternate-screen TUI (Claude Code, vim, etc.) is currently + // active, the visible content is the alt-screen snapshot — replaying any + // normal-buffer scrollback before it can duplicate shell prompts and + // flatten SGR attributes when the mobile xterm replays the data. Force + // scrollbackRows=0 in that case. When the buffer is in normal mode the + // caller can request scrollback so the user can scroll up to see prior + // agent output. + const requested = opts.scrollbackRows ?? 0 + const scrollbackRows = state.emulator.isAlternateScreen ? 0 : requested + const snapshot = state.emulator.getSnapshot({ scrollbackRows }) + const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi + return data.length > 0 ? { data, cols: snapshot.cols, rows: snapshot.rows } : null + } + + private disposeHeadlessTerminal(ptyId: string): void { + this.headlessHydrationState.delete(ptyId) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + this.headlessTerminals.delete(ptyId) + state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) + } + + resolveLeafForHandle(handle: string): { ptyId: string | null } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (record.tabId.startsWith('pty:')) { + return { ptyId: record.ptyId } + } + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf) { + return null + } + return { ptyId: leaf.ptyId } + } + + registerSubscriptionCleanup( + subscriptionId: string, + cleanup: () => void, + connectionId?: string + ): void { + // Why: mobile clients reconnect frequently (phone lock, network switch). + // The RPC client re-sends terminal.subscribe on reconnect, creating a new + // handler before the old one is cleaned up. Without this, the old data + // listener leaks in dataListeners and duplicates every PTY data event. + const existing = this.subscriptionCleanups.get(subscriptionId) + if (existing) { + existing() + // Why: existing() already evicts itself from the per-connection index + // via cleanupSubscription, so no extra bookkeeping is needed here. + } + this.subscriptionCleanups.set(subscriptionId, cleanup) + if (connectionId) { + let set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + set = new Set() + this.subscriptionsByConnection.set(connectionId, set) + } + set.add(subscriptionId) + this.subscriptionConnectionByEntry.set(subscriptionId, connectionId) + } + } + + cleanupSubscription(subscriptionId: string): void { + const cleanup = this.subscriptionCleanups.get(subscriptionId) + if (cleanup) { + this.subscriptionCleanups.delete(subscriptionId) + const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) + if (connectionId) { + this.subscriptionConnectionByEntry.delete(subscriptionId) + const set = this.subscriptionsByConnection.get(connectionId) + if (set) { + set.delete(subscriptionId) + if (set.size === 0) { + this.subscriptionsByConnection.delete(connectionId) + } + } + } + cleanup() + } + } + + // Why: invoked from the WebSocket transport's on-close hook so streaming + // listeners registered for this exact socket get torn down even when other + // sockets sharing the same deviceToken are still alive (multi-screen + // mobile). Without this sweep, listeners leak across every reconnect. + cleanupSubscriptionsForConnection(connectionId: string): void { + const set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + return + } + // Why: snapshot the ids before iterating because cleanupSubscription + // mutates both the set and the index map. + const ids = Array.from(set) + for (const id of ids) { + this.cleanupSubscription(id) + } + } + + // Why: mobile clients subscribe via notifications.subscribe streaming RPC. + // Each subscriber gets its own listener. Returns an unsubscribe function + // that the subscription cleanup mechanism calls on disconnect. + onNotificationDispatched(listener: (event: MobileNotificationEvent) => void): () => void { + this.notificationListeners.add(listener) + return () => { + this.notificationListeners.delete(listener) + } + } + + getMobileNotificationListenerCount(): number { + return this.notificationListeners.size + } + + dispatchMobileNotification(event: MobileNotificationEvent): void { + for (const listener of this.notificationListeners) { + listener(event) + } + } + + // ─── Account Services (mobile RPC bridge) ───────────────────── + + setAccountServices(services: RuntimeAccountServices): void { + this.accountServices = services + } + + private requireAccountServices(): RuntimeAccountServices { + if (!this.accountServices) { + throw new Error('Account services are not configured on this runtime') + } + return this.accountServices + } + + getAccountsSnapshot(): AccountsSnapshot { + const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices() + return { + claude: claudeAccounts.listAccounts(), + codex: codexAccounts.listAccounts(), + rateLimits: rateLimits.getState() + } + } + + // Why: RateLimitService polls only when the Electron window is visible AND + // focused, and the inactive-account caches fill lazily when the user opens + // the desktop AccountsPane. Mobile has neither trigger, so without this the + // phone shows 0% / "—" against a backgrounded desktop. Errors swallowed + // because partial usage is still useful for the rest of the snapshot. + async refreshAccountsForMobile(): Promise { + const { rateLimits } = this.requireAccountServices() + await Promise.allSettled([ + rateLimits.refresh(), + rateLimits.fetchInactiveClaudeAccountsOnOpen(), + rateLimits.fetchInactiveCodexAccountsOnOpen() + ]) + } + + selectClaudeAccount(accountId: string | null): Promise { + return this.requireAccountServices().claudeAccounts.selectAccount(accountId) + } + + selectCodexAccount(accountId: string | null): Promise { + return this.requireAccountServices().codexAccounts.selectAccount(accountId) + } + + removeClaudeAccount(accountId: string): Promise { + return this.requireAccountServices().claudeAccounts.removeAccount(accountId) + } + + removeCodexAccount(accountId: string): Promise { + return this.requireAccountServices().codexAccounts.removeAccount(accountId) + } + + // Why: rate-limit polling fires every 5 minutes and on account switch. + // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever + // RateLimitService pushes new usage data, mirroring the existing + // `rateLimits:update` IPC channel desktop already uses. + onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { + const services = this.requireAccountServices() + return services.rateLimits.onStateChange(() => { + listener({ + claude: services.claudeAccounts.listAccounts(), + codex: services.codexAccounts.listAccounts(), + rateLimits: services.rateLimits.getState() + }) + }) + } + + // ─── Mobile Fit Override Management ───────────────────────── + + // Why: legacy mobile RPC entrypoint. After the state-machine rewrite this + // is a thin shim that computes a `PtyLayoutTarget` and routes through + // `enqueueLayout`. Keeps the same observable return shape so older mobile + // builds continue to work. See docs/mobile-terminal-layout-state-machine.md. + async resizeForClient( + ptyId: string, + mode: 'mobile-fit' | 'restore', + clientId: string, + cols?: number, + rows?: number + ): Promise<{ + cols: number + rows: number + previousCols: number | null + previousRows: number | null + mode: 'mobile-fit' | 'desktop-fit' + }> { + if (mode === 'mobile-fit') { + if (cols == null || rows == null || !Number.isFinite(cols) || !Number.isFinite(rows)) { + throw new Error('invalid_dimensions') + } + const clampedCols = Math.max(20, Math.min(240, Math.round(cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(rows))) + + const currentSize = this.getTerminalSize(ptyId) + const existing = this.terminalFitOverrides.get(ptyId) + // Capture baseline cols/rows for the return value (existing override's + // baseline wins over current size to preserve original desktop dims + // across multiple re-fits). + const previousCols = existing?.previousCols ?? currentSize?.cols ?? null + const previousRows = existing?.previousRows ?? currentSize?.rows ?? null + + // Why: legacy resizeForClient callers bypass handleMobileSubscribe, so + // mobileSubscribers stays empty and resolveDesktopRestoreTarget's step-1 + // (per-subscriber baseline) never matches. Stash the pre-fit PTY size + // into lastRendererSizes so restore lands on step 2 (renderer geometry) + // instead of step 3 (current phone-fit dims = no-op restore). + if (currentSize && !existing) { + this.lastRendererSizes.set(ptyId, { + cols: currentSize.cols, + rows: currentSize.rows + }) + } + + this.freshSubscribeGuard.add(ptyId) + let result: ApplyLayoutResult + try { + result = await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + if (!result.ok) { + throw new Error('resize_failed') + } + + // Why: mobile-fit via resizeForClient is a deliberate mobile action; + // the actor takes the floor (updates lastActedAt; mode-flip case is + // already handled by enqueueLayout above). + await this.mobileTookFloor(ptyId, clientId) + + return { + cols: clampedCols, + rows: clampedRows, + previousCols, + previousRows, + mode: 'mobile-fit' + } + } + + // restore mode + const override = this.terminalFitOverrides.get(ptyId) + if (!override) { + throw new Error('no_active_override') + } + // Only the owning client can restore — prevents one phone from undoing + // another phone's active fit. + if (override.clientId !== clientId) { + throw new Error('not_override_owner') + } + + const restore = this.resolveDesktopRestoreTarget(ptyId) + const result = await this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows + }) + if (!result.ok) { + throw new Error('resize_failed') + } + + // Why: legacy mobile clients on the resizeForClient path also need a + // fit-override-listener notification (the renderer-side terminalFitOverrideChanged + // is already emitted by applyLayout's mode-flip path). + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', restore.cols, restore.rows) + + return { + cols: restore.cols, + rows: restore.rows, + previousCols: null, + previousRows: null, + mode: 'desktop-fit' + } + } + + getTerminalFitOverride(ptyId: string) { + return this.terminalFitOverrides.get(ptyId) ?? null + } + + getAllTerminalFitOverrides(): Map { + const result = new Map() + for (const [ptyId, override] of this.terminalFitOverrides) { + result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows }) + } + return result + } + + onClientDisconnected(clientId: string): void { + // (1) Cancel pending restore-debounce timers owned by this client. + for (const [ptyId, entry] of this.pendingRestoreTimers) { + if (entry.clientId === clientId) { + clearTimeout(entry.timer) + this.pendingRestoreTimers.delete(ptyId) + } + } + + // (2) Promote any soft-leave grace owned by this client into immediate + // finalization. Grace existed to absorb a quick re-subscribe; a real + // disconnect kills any chance of re-subscribe. + // + // Note: this is mode-decoupled (matches docs/mobile-terminal-layout-state-machine.md + // sub-case 2). Today's pre-rewrite code only restored when + // `mode === 'auto' && wasResizedToPhone`; the new design restores + // whenever the layout is currently `phone`. This is an intentional + // behavior fix — `mode === 'phone'` with no subscribers is a degenerate + // state nothing in product depends on. + for (const [ptyId, soft] of this.pendingSoftLeavers) { + if (soft.clientId !== clientId) { + continue + } + clearTimeout(soft.timer) + this.pendingSoftLeavers.delete(ptyId) + + // Cancel any in-flight 300ms restore timer too — we'll handle it inline. + const pending = this.pendingRestoreTimers.get(ptyId) + if (pending) { + clearTimeout(pending.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + const cur = this.layouts.get(ptyId) + // Why: Indefinite hold (mobileAutoRestoreFitMs == null) keeps the PTY + // at phone dims after the phone disconnects; the desktop banner's + // Restore button is the explicit return path. See + // docs/mobile-fit-hold.md. + if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + // Use the soft-leaver's snapshot baseline as a hint, falling + // through to resolveDesktopRestoreTarget for missing values. + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = soft.record.previousCols ?? fallback.cols + const rows = soft.record.previousRows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + } + this.setDriver(ptyId, { kind: 'idle' }) + } + + // (3) Immediate restore for PTYs where this client was the last + // mobile subscriber. With multi-mobile, peer subscribers keep the + // floor; only when the inner map empties do we transition to desktop. + const ptysWithSurvivingPeers: string[] = [] + const ptysToRestore: { ptyId: string; baseline: { cols: number; rows: number } | null }[] = [] + for (const [ptyId, inner] of this.mobileSubscribers) { + const subscriber = inner.get(clientId) + if (!subscriber) { + continue + } + // Snapshot baseline before deleting — needed once mobileSubscribers + // entry is gone for the resolveDesktopRestoreTarget chain. + const baseline = + subscriber.previousCols != null && subscriber.previousRows != null + ? { cols: subscriber.previousCols, rows: subscriber.previousRows } + : null + inner.delete(clientId) + if (inner.size > 0) { + ptysWithSurvivingPeers.push(ptyId) + } else { + this.mobileSubscribers.delete(ptyId) + ptysToRestore.push({ ptyId, baseline }) + } + } + for (const { ptyId, baseline } of ptysToRestore) { + const cur = this.layouts.get(ptyId) + // Why: Indefinite hold gate — see soft-leaver branch above. + if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = baseline?.cols ?? fallback.cols + const rows = baseline?.rows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + } + this.setDriver(ptyId, { kind: 'idle' }) + } + + // (4) Driver re-election where peers survived. If the disconnecting + // client was the active driver, the most-recent surviving actor takes + // the floor. + for (const ptyId of ptysWithSurvivingPeers) { + const driver = this.getDriver(ptyId) + if (driver.kind !== 'mobile' || driver.clientId !== clientId) { + continue + } + const inner = this.mobileSubscribers.get(ptyId) + const next = inner ? this.pickMostRecentActor(inner) : null + if (!next) { + continue + } + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (mode === 'desktop') { + continue + } + const nextSub = inner!.get(next.clientId) + const nextViewport = nextSub?.viewport + if (!nextViewport) { + continue + } + void this.enqueueLayout(ptyId, { + kind: 'phone', + cols: nextViewport.cols, + rows: nextViewport.rows, + ownerClientId: next.clientId + }) + } + + // (5) Legacy-callers fallback. Older mobile builds use resizeForClient + // directly and never populate mobileSubscribers. For those PTYs the + // override carries the owning clientId; restore the layout when the + // owner disconnects. resolveDesktopRestoreTarget reads lastRendererSizes + // (which the legacy mobile-fit branch stashes the pre-fit size into). + for (const [ptyId, override] of this.terminalFitOverrides) { + if (override.clientId !== clientId) { + continue + } + if (this.mobileSubscribers.has(ptyId)) { + continue + } + const cur = this.layouts.get(ptyId) + if (cur?.kind !== 'phone') { + continue + } + // Why: Indefinite hold gate — see soft-leaver branch above. Legacy + // mobile clients (resizeForClient path) honor the same setting. + if (this.getAutoRestoreFitMs() == null) { + continue + } + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = override.previousCols ?? fallback.cols + const rows = override.previousRows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + } } onPtyExit(ptyId: string, exitCode: number): void { + // Clean up new mobile state for this PTY + this.mobileSubscribers.delete(ptyId) + this.mobileDisplayModes.delete(ptyId) + this.resizeListeners.delete(ptyId) + this.lastRendererSizes.delete(ptyId) + // Layout state machine: clear `layouts` and `layoutQueues`. Any + // already-queued applyLayout work for this ptyId will run, but every + // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and + // short-circuits with `pty-exited`. + this.layouts.delete(ptyId) + this.layoutQueues.delete(ptyId) + this.freshSubscribeGuard.delete(ptyId) + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + const pendingSoft = this.pendingSoftLeavers.get(ptyId) + if (pendingSoft) { + clearTimeout(pendingSoft.timer) + this.pendingSoftLeavers.delete(ptyId) + } + + if (this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + } + // Why: clear driver state and notify the renderer so any lock banner on + // this dead pane unmounts. Without this, the pane shows a stuck banner + // until tab teardown, and `getDriver(deadPtyId)` would keep returning a + // stale `mobile{X}` to any caller that hasn't yet seen the exit IPC. + if (this.currentDriver.has(ptyId)) { + this.currentDriver.delete(ptyId) + this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' }) + } + this.disposeHeadlessTerminal(ptyId) this.agentDetector?.onExit(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.connected = false + pty.lastExitCode = exitCode + this.resolvePtyExitWaiters(pty, ptyId) + } for (const leaf of this.leaves.values()) { if (leaf.ptyId !== ptyId) { @@ -514,6 +1764,1022 @@ export class OrcaRuntimeService { } } + // ─── Driver state (mobile-presence lock) ────────────────────────── + // + // See docs/mobile-presence-lock.md. + + getDriver(ptyId: string): DriverState { + return this.currentDriver.get(ptyId) ?? { kind: 'idle' } + } + + private setDriver(ptyId: string, next: DriverState): void { + const prev = this.getDriver(ptyId) + if (prev.kind === next.kind) { + if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { + return + } + if (prev.kind !== 'mobile' && next.kind !== 'mobile') { + return + } + } + if (next.kind === 'idle') { + this.currentDriver.delete(ptyId) + } else { + this.currentDriver.set(ptyId, next) + } + this.notifier?.terminalDriverChanged(ptyId, next) + } + + // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / + // resizeForClient / fresh subscribe with auto). Records the actor as the + // most recent mobile driver and re-applies phone-fit if we were previously + // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs + // are no-ops for resize. + async mobileTookFloor(ptyId: string, clientId: string): Promise { + const inner = this.mobileSubscribers.get(ptyId) + const sub = inner?.get(clientId) + if (sub) { + sub.lastActedAt = Date.now() + } + const prev = this.getDriver(ptyId) + const currentMode = this.mobileDisplayModes.get(ptyId) + // Why: a deliberate mobile action implies mobile is resuming control. + // If the display mode is currently 'desktop' (set by an earlier + // take-back), flip it back to 'auto' (= map absence) and re-apply so + // phone-fit takes hold again. See docs/mobile-presence-lock.md. + if (prev.kind === 'desktop' || currentMode === 'desktop') { + if (currentMode === 'desktop') { + this.mobileDisplayModes.delete(ptyId) + } + await this.applyMobileDisplayMode(ptyId) + } + this.setDriver(ptyId, { kind: 'mobile', clientId }) + } + + // Why: in-place viewport update on the existing mobile subscription — + // used when the mobile keyboard opens/closes and shrinks/grows the + // visible terminal area. We refresh the subscriber's viewport, re-fit + // the PTY to the new dims, and emit a 'resized' event so the mobile + // xterm reinits inline at the new dims without re-subscribing. This + // avoids the unsubscribe → resubscribe cycle which would (a) flash the + // desktop lock banner during the brief idle gap and (b) cause the new + // subscribe to capture the already-phone-fitted PTY size as its + // restore baseline (stuck-dim bug on later disconnect). + // No-op when the client isn't actually subscribed to this PTY. + async updateMobileViewport( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } + ): Promise { + const inner = this.mobileSubscribers.get(ptyId) + const sub = inner?.get(clientId) + if (!sub) { + return false + } + sub.viewport = viewport + sub.lastActedAt = Date.now() + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (mode === 'desktop') { + // Watching at desktop dims — viewport is informational only. + return true + } + // Drive PTY dims by the most-recent-actor (just updated to this client). + const winner = this.pickMostRecentActor(inner!) + if (!winner) { + return false + } + const winnerSub = inner!.get(winner.clientId) + const driveViewport = winnerSub?.viewport ?? viewport + const clampedCols = Math.max(20, Math.min(240, Math.round(driveViewport.cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(driveViewport.rows))) + + sub.wasResizedToPhone = true + // The driver is already mobile{this client} when we got here; refresh + // to update lastActedAt-based ordering on later actor selection. + this.setDriver(ptyId, { kind: 'mobile', clientId }) + + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: winner.clientId + }) + return true + } + + // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take + // back" / "Restore" button). Forces the PTY back to desktop dims and + // flips the driver to `desktop`, suppressing further mobile-driven dim + // changes until a mobile actor takes the floor again. Two cases: + // 1. Active mobile subscriber: route through applyMobileDisplayMode so + // the existing 'resized' event reaches the phone. + // 2. Held with no mobile subscriber (post-indefinite-hold): no inner + // subscriber to notify; resolve restore target and enqueueLayout + // directly. applyLayout is the SOLE writer of terminalFitOverrides; + // the held branch must not duplicate that mutation. See + // docs/mobile-fit-hold.md. + async reclaimTerminalForDesktop(ptyId: string): Promise { + if (this.isMobileSubscriberActive(ptyId)) { + this.setMobileDisplayMode(ptyId, 'desktop') + await this.applyMobileDisplayMode(ptyId) + this.setDriver(ptyId, { kind: 'desktop' }) + // Why: a desktop-initiated reclaim is "I'm taking over right now", + // not a sticky preference. The next mobile subscribe (e.g. user + // switches back to the terminal tab on the phone) must default to + // phone-fit again, not stay in passive desktop-watch mode. + this.setMobileDisplayMode(ptyId, 'auto') + return true + } + const heldOverride = this.terminalFitOverrides.get(ptyId) + if (heldOverride) { + const pending = this.pendingRestoreTimers.get(ptyId) + if (pending) { + clearTimeout(pending.timer) + this.pendingRestoreTimers.delete(ptyId) + } + // Why: with no subscribers, resolveDesktopRestoreTarget falls through + // to current PTY size — which is at phone dims (wrong). Prefer the + // baseline captured on the override at first phone-fit; this is the + // last desktop geometry the layout machine knew about. Then chain + // to the standard resolver for the residual fallbacks. + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = heldOverride.previousCols ?? fallback.cols + const rows = heldOverride.previousRows ?? fallback.rows + await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + this.setDriver(ptyId, { kind: 'desktop' }) + // Why: a desktop-initiated reclaim is "I'm taking over right now", + // not a sticky preference. Reset to auto so the next mobile subscribe + // re-enters phone-fit. (Held-PTY branch may not have an entry, but + // calling setMobileDisplayMode('auto') is a no-op deletion in that + // case — safe and idempotent.) + this.setMobileDisplayMode(ptyId, 'auto') + return true + } + return false + } + + // Why: read-side clamp for mobileAutoRestoreFitMs. `null` means + // indefinite hold (no auto-restore timer). A finite value is clamped + // to [MIN, MAX] to defend against bad config — the smallest useful + // value is a few seconds, the largest is one hour. See + // docs/mobile-fit-hold.md. + private getAutoRestoreFitMs(): number | null { + const raw = this.store?.getSettings().mobileAutoRestoreFitMs ?? null + if (raw == null) { + return null + } + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null + } + return Math.min(Math.max(raw, MOBILE_AUTO_RESTORE_FIT_MIN_MS), MOBILE_AUTO_RESTORE_FIT_MAX_MS) + } + + // Why: invoked when the user changes mobileAutoRestoreFitMs to `null` + // (Indefinite). Clears every pending restore timer so the just-expressed + // preference "do not auto-restore" is honored for ALL currently-pending + // PTYs, not just one. See docs/mobile-fit-hold.md. + cancelAllPendingFitRestoreTimers(): void { + for (const [, entry] of this.pendingRestoreTimers) { + clearTimeout(entry.timer) + } + this.pendingRestoreTimers.clear() + } + + // Why: read the persisted user preference (clamped) for surfacing to UI + // callers (mobile RPC, desktop preferences). Returns null when the + // setting is unset or `null` ("Indefinite"). + getMobileAutoRestoreFitMs(): number | null { + return this.getAutoRestoreFitMs() + } + + // Why: persisted-preference setter routed through the same `Store` the + // desktop preferences UI writes to. Transitions to `null` (Indefinite) + // clear every pending restore timer to honor the preference change for + // already-held PTYs. Transitions to a finite value do NOT retroactively + // schedule timers for PTYs that are currently held — those PTYs were + // already-not-restored under the old preference, and silently scheduling + // a restore on a settings change would be surprising. The new value + // takes effect on the next unsubscribe. See docs/mobile-fit-hold.md. + setMobileAutoRestoreFitMs(ms: number | null): number | null { + if (!this.store?.updateSettings) { + return this.getAutoRestoreFitMs() + } + let normalized: number | null + if (ms == null) { + normalized = null + } else if (typeof ms !== 'number' || !Number.isFinite(ms)) { + normalized = null + } else { + normalized = Math.min( + Math.max(ms, MOBILE_AUTO_RESTORE_FIT_MIN_MS), + MOBILE_AUTO_RESTORE_FIT_MAX_MS + ) + } + this.store.updateSettings({ mobileAutoRestoreFitMs: normalized }) + if (normalized == null) { + this.cancelAllPendingFitRestoreTimers() + } + return normalized + } + + // Why: with multiple subscribers, the active phone-fit dims follow the + // most recent mobile actor (argmax(lastActedAt)). See + // docs/mobile-presence-lock.md "Active phone-fit dim selection". + private pickMostRecentActor( + inner: Map + ): { clientId: string; lastActedAt: number } | null { + let best: { clientId: string; lastActedAt: number } | null = null + for (const sub of inner.values()) { + if (best === null || sub.lastActedAt > best.lastActedAt) { + best = sub + } + } + return best + } + + // Why: restore-target selection on last-subscriber-leaves picks the + // earliest-by-subscribe-time subscriber AMONG those with non-null + // previousCols/Rows. Desktop-mode joins carry null and are skipped — they + // never captured pre-fit dims by design. + private pickEarliestRestoreTarget( + inner: Map< + string, + { subscribedAt: number; previousCols: number | null; previousRows: number | null } + > + ): { previousCols: number; previousRows: number } | null { + let best: { subscribedAt: number; previousCols: number; previousRows: number } | null = null + for (const sub of inner.values()) { + if (sub.previousCols == null || sub.previousRows == null) { + continue + } + if (best === null || sub.subscribedAt < best.subscribedAt) { + best = { + subscribedAt: sub.subscribedAt, + previousCols: sub.previousCols, + previousRows: sub.previousRows + } + } + } + return best ? { previousCols: best.previousCols, previousRows: best.previousRows } : null + } + + // ─── Layout state machine ───────────────────────────────────────── + // + // See docs/mobile-terminal-layout-state-machine.md. + // + // applyLayout is the SOLE writer of: + // - this.layouts + // - this.terminalFitOverrides + // - this.ptyController.resize (i.e. the actual PTY dims) + // + // Every trigger that wants to change PTY dims or flip mode goes through + // enqueueLayout, which serializes calls behind a per-PTY async queue + // (the await on ptyController.resize would otherwise let seq bumps reach + // the wire out of order). + + getLayout(ptyId: string): PtyLayoutState | null { + return this.layouts.get(ptyId) ?? null + } + + // Why: `enqueueLayout`'s "no layouts entry" short-circuit must not fire + // on the very first transition for a PTY (where the entry doesn't exist + // yet *because* we're about to create it). handleMobileSubscribe adds + // the ptyId to `freshSubscribeGuard` before calling enqueueLayout and + // removes it in a finally block. + private isFreshSubscribe(ptyId: string): boolean { + return this.freshSubscribeGuard.has(ptyId) + } + + // Why: four-step fallback chain for desktop-restore targets. Always + // returns a value; the terminal {80,24} branch is reached only under + // bug. Wrapping the chain as a single helper prevents callsite drift. + private resolveDesktopRestoreTarget(ptyId: string): { cols: number; rows: number } { + // 1. Earliest-by-subscribedAt subscriber with non-null baseline. + const inner = this.mobileSubscribers.get(ptyId) + if (inner) { + const earliest = this.pickEarliestRestoreTarget(inner) + if (earliest) { + return { cols: earliest.previousCols, rows: earliest.previousRows } + } + } + // 2. Most-recent desktop renderer geometry report. + const renderer = this.lastRendererSizes.get(ptyId) + if (renderer) { + return { cols: renderer.cols, rows: renderer.rows } + } + // 3. Current PTY size. + const size = this.getTerminalSize(ptyId) + if (size) { + return { cols: size.cols, rows: size.rows } + } + // 4. Hard default. + return { cols: 80, rows: 24 } + } + + // Why: a new viewport-only update from the same owner supersedes a + // queued same-shape tail. Mode flips, owner changes, and take-back + // append (losing a take-floor to a viewport tick would be a fairness + // hole — see "enqueueLayout coalescing" in the design doc). + private coalescesWith(prev: PtyLayoutTarget, next: PtyLayoutTarget): boolean { + if (prev.kind !== next.kind) { + return false + } + if (prev.kind === 'phone' && next.kind === 'phone') { + return prev.ownerClientId === next.ownerClientId + } + return true + } + + private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // Why: PTY-exit short-circuit. Fresh-subscribe gate lets the very first + // transition through even though `layouts` has no entry yet. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return Promise.resolve({ ok: false, reason: 'pty-exited' }) + } + + let entry = this.layoutQueues.get(ptyId) + if (!entry) { + entry = { running: null, pending: [] } + this.layoutQueues.set(ptyId, entry) + } + const queue = entry + + return new Promise((resolve) => { + if (!queue.running) { + queue.running = this.runLayoutSlot(ptyId, target, [resolve]) + return + } + const tail = queue.pending.at(-1) + if (tail && this.coalescesWith(tail.target, target)) { + tail.target = target + tail.waiters.push(resolve) + return + } + queue.pending.push({ target, waiters: [resolve] }) + }) + } + + private async runLayoutSlot( + ptyId: string, + target: PtyLayoutTarget, + waiters: ((r: ApplyLayoutResult) => void)[] + ): Promise { + let result: ApplyLayoutResult + try { + result = await this.applyLayout(ptyId, target) + } catch (err) { + // Why: defensive — applyLayout itself catches resize errors, but a + // throw from one of the synchronous map writes (e.g. notifier hook) + // must not jam the queue forever. + console.error('[layout] applyLayout threw', { ptyId, err }) + result = { ok: false, reason: 'resize-failed' } + } + for (const w of waiters) { + w(result) + } + + const queue = this.layoutQueues.get(ptyId) + if (!queue) { + return result + } + const next = queue.pending.shift() + if (next) { + queue.running = this.runLayoutSlot(ptyId, next.target, next.waiters) + } else { + queue.running = null + // Why: drop the entry once empty so the map doesn't grow without bound + // across short-lived PTYs. + this.layoutQueues.delete(ptyId) + } + return result + } + + private async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // Why: re-check pty-exit at the head of the slot — the queue may have + // accepted this target before onPtyExit ran. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return { ok: false, reason: 'pty-exited' } + } + + const prev = this.layouts.get(ptyId) ?? null + const seq = (prev?.seq ?? 0) + 1 + const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() } + + const currentSize = this.getTerminalSize(ptyId) + const dimsChanged = currentSize?.cols !== target.cols || currentSize?.rows !== target.rows + const modeChanged = (prev?.kind ?? 'desktop') !== target.kind + + // Snapshot for rollback. + const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null + + // Tentative writes — the resize is the point of no return. + this.layouts.set(ptyId, next) + if (target.kind === 'phone') { + // Why: pull baseline cols+rows atomically from the same subscriber so + // they can't desync. + const baseline = (() => { + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return null + } + return this.pickEarliestRestoreTarget(inner) + })() + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: target.cols, + rows: target.rows, + previousCols: baseline?.previousCols ?? null, + previousRows: baseline?.previousRows ?? null, + updatedAt: next.appliedAt, + clientId: target.ownerClientId + }) + } else { + this.terminalFitOverrides.delete(ptyId) + } + + if (dimsChanged) { + let ok = false + try { + const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows) + ok = r ?? true + } catch (err) { + console.error('[layout] ptyController.resize threw', { ptyId, err }) + ok = false + } + if (!ok) { + // Roll back to pre-call snapshot. seq is NOT bumped on the wire + // because we never emit below. + if (prev) { + this.layouts.set(ptyId, prev) + } else { + this.layouts.delete(ptyId) + } + if (prevFitOverride) { + this.terminalFitOverrides.set(ptyId, prevFitOverride) + } else { + this.terminalFitOverrides.delete(ptyId) + } + return { ok: false, reason: 'resize-failed' } + } + this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) + } + + // Why: emit fit-override-changed only when the *mode* flips. Layouts + // can change dims without flipping mode (keyboard show/hide while + // phone), and waking the renderer on every viewport tick is wasteful + // churn. + if (modeChanged) { + // Why: phone→desktop arms the renderer-cascade suppress window + // before the collateral safeFit IPCs arrive. See "Renderer cascade + // suppression". + if (target.kind === 'desktop') { + this.lastRendererSizes.delete(ptyId) + this.suppressResizesForMs(500) + } + this.notifier?.terminalFitOverrideChanged( + ptyId, + target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.cols, + target.rows + ) + this.notifyFitOverrideListeners( + ptyId, + target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.cols, + target.rows + ) + } + + // Mobile-facing event always fires (phone clients need to re-fit on + // every dim change, not just mode flips). + this.notifyTerminalResize(ptyId, { + cols: target.cols, + rows: target.rows, + displayMode: target.kind === 'phone' ? 'phone' : 'desktop', + reason: 'apply-layout', + seq + }) + + return { ok: true, state: next } + } + + // ─── Server-Authoritative Mobile Display Mode ───────────────────── + + setMobileDisplayMode(ptyId: string, mode: 'auto' | 'desktop'): void { + if (mode === 'auto') { + this.mobileDisplayModes.delete(ptyId) + } else { + this.mobileDisplayModes.set(ptyId, mode) + } + } + + getMobileDisplayMode(ptyId: string): 'auto' | 'desktop' { + return this.mobileDisplayModes.get(ptyId) ?? 'auto' + } + + isMobileSubscriberActive(ptyId: string): boolean { + const inner = this.mobileSubscribers.get(ptyId) + return inner !== undefined && inner.size > 0 + } + + // Why: late-bind viewport on an existing subscriber record. Subscribers + // that registered before the mobile side measured (e.g. terminal first + // mounted while the WebView was still loading) have null viewport, and + // applyMobileDisplayMode's auto branch needs a viewport to phone-fit. + // The setDisplayMode RPC carries the latest viewport so we can patch it + // here just before applyMobileDisplayMode runs. + updateMobileSubscriberViewport( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } + ): void { + const inner = this.mobileSubscribers.get(ptyId) + const record = inner?.get(clientId) + if (!record) { + return + } + record.viewport = viewport + } + + // Why: server-side auto-fit on mobile subscribe. The runtime is the single + // source of truth — the mobile client just passes its viewport and the runtime + // decides whether to resize. This eliminates the measure→RPC→resubscribe + // pipeline that caused race conditions. + // + // Multi-mobile keying: each subscriber lives in `mobileSubscribers[ptyId]`'s + // inner map under its own clientId. Phone B subscribing does not overwrite + // phone A's record — both stay until each unsubscribes. + // + // Subscribe-in-desktop-mode rule: a subscribe with displayMode='desktop' is + // a passive watch; it does NOT take the floor. The driver remains + // `idle`/`desktop`. The lock banner is reserved for actual mobile + // interaction (input/resize/setDisplayMode/auto-or-phone subscribe). + async handleMobileSubscribe( + ptyId: string, + clientId: string, + viewport?: { cols: number; rows: number } + ): Promise { + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (!viewport) { + return false + } + + // Cancel pending restore timer for this ptyId — any new subscriber + // supersedes any old client's pending restore. + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + const clampedCols = Math.max(20, Math.min(240, Math.round(viewport.cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + + // Resubscribe-grace honor: same client returning within soft-leave + // window restores prior record (preserving baseline so we don't capture + // phone-fitted dims as the new baseline). + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (softLeaver && softLeaver.clientId === clientId) { + clearTimeout(softLeaver.timer) + this.pendingSoftLeavers.delete(ptyId) + let inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + inner = new Map() + this.mobileSubscribers.set(ptyId, inner) + } + inner.set(clientId, { + ...softLeaver.record, + viewport, + lastActedAt: Date.now() + }) + this.setDriver(ptyId, { kind: 'mobile', clientId }) + if (mode !== 'desktop') { + this.freshSubscribeGuard.add(ptyId) + try { + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + } + return true + } + + let inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + inner = new Map() + this.mobileSubscribers.set(ptyId, inner) + } + + // Capture restore baseline BEFORE applyLayout writes the override. + // Multi-mobile: peer joiner against an already-fitted PTY captures null + // — the existing baseline-holder's snapshot remains canonical. See + // docs/mobile-presence-lock.md. + // + // Resubscribe-after-indefinite-hold: the held override carries the only + // authoritative pre-fit dims across the no-subscriber gap. Inherit it + // first; otherwise rendererSize/currentSize would be the held phone dims + // and applyLayout would clobber the override's previousCols with phone + // dims, making any subsequent Restore a no-op. + const heldOverride = this.terminalFitOverrides.get(ptyId) + const existing = inner.get(clientId) + const someoneAlreadyFitted = [...inner.values()].some((s) => s.wasResizedToPhone) + const currentSize = this.getTerminalSize(ptyId) + const rendererSize = this.lastRendererSizes.get(ptyId) + const previousCols = + existing?.previousCols ?? + heldOverride?.previousCols ?? + (someoneAlreadyFitted ? null : (rendererSize?.cols ?? currentSize?.cols ?? null)) + const previousRows = + existing?.previousRows ?? + heldOverride?.previousRows ?? + (someoneAlreadyFitted ? null : (rendererSize?.rows ?? currentSize?.rows ?? null)) + const now = Date.now() + const subscribedAt = existing?.subscribedAt ?? now + + if (mode === 'desktop') { + // Passive watch — null baseline (we'll capture later if user toggles + // to auto/phone, since safeFit will have converged by then). Do not + // flip driver. + inner.set(clientId, { + clientId, + viewport, + wasResizedToPhone: false, + previousCols: null, + previousRows: null, + subscribedAt, + lastActedAt: now + }) + return false + } + + inner.set(clientId, { + clientId, + viewport, + wasResizedToPhone: true, + previousCols, + previousRows, + subscribedAt, + lastActedAt: now + }) + + // Subscribe-fresh with auto/phone counts as "take the floor". + this.setDriver(ptyId, { kind: 'mobile', clientId }) + + // Route the actual resize through the state machine. The fresh-subscribe + // gate lets enqueueLayout's "no layouts entry" short-circuit pass on + // the very first transition for this PTY. + this.freshSubscribeGuard.add(ptyId) + try { + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + + return true + } + + // Why: delayed restore prevents resize thrashing during rapid tab switches. + // The 300ms debounce means only the final tab triggers a PTY restore; + // intermediate terminals keep their current dims harmlessly. + // + // Multi-mobile: only the last subscriber leaving for this ptyId triggers + // restore + driver=idle. Peer mobile clients still on the inner map keep + // the lock banner mounted; if the disconnecting client was the active + // driver, we re-elect the most-recent surviving subscriber. + handleMobileUnsubscribe(ptyId: string, clientId: string): void { + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return + } + const subscriber = inner.get(clientId) + if (!subscriber) { + return + } + const wasResizedToPhone = subscriber.wasResizedToPhone + + inner.delete(clientId) + + if (inner.size > 0) { + // Why: if the leaving client was the only one with a non-null restore + // baseline (typical when peer joiners subscribed against an + // already-phone-fitted PTY and got null prevCols), donate the baseline + // to the earliest surviving subscriber so a future last-leaver can + // still restore correctly. See docs/mobile-presence-lock.md. + if ( + subscriber.previousCols != null && + subscriber.previousRows != null && + !this.pickEarliestRestoreTarget(inner) + ) { + let earliestSurvivor: { clientId: string; subscribedAt: number } | null = null + for (const sub of inner.values()) { + if (earliestSurvivor === null || sub.subscribedAt < earliestSurvivor.subscribedAt) { + earliestSurvivor = { clientId: sub.clientId, subscribedAt: sub.subscribedAt } + } + } + if (earliestSurvivor) { + const heir = inner.get(earliestSurvivor.clientId) + if (heir) { + heir.previousCols = subscriber.previousCols + heir.previousRows = subscriber.previousRows + } + } + } + // Peers still on the line. If the disconnecting client was the active + // mobile driver, re-elect the most-recent surviving subscriber so the + // banner remains correct and active phone-fit dims follow them. + const driver = this.getDriver(ptyId) + if (driver.kind === 'mobile' && driver.clientId === clientId) { + const next = this.pickMostRecentActor(inner) + if (next) { + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + // Fire-and-forget — handleMobileUnsubscribe stays sync; applyLayout + // failures self-recover on the next gesture. + void this.applyMobileDisplayMode(ptyId) + } + } + return + } + + // Last subscriber leaving — clean up. + this.mobileSubscribers.delete(ptyId) + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + + // Resubscribe-grace: hold driver=mobile{clientId} for ~250ms so a quick + // re-subscribe (older clients without updateViewport) doesn't flash the + // desktop banner. See docs/mobile-presence-lock.md. + const SOFT_LEAVE_GRACE_MS = 250 + const existingSoft = this.pendingSoftLeavers.get(ptyId) + if (existingSoft) { + clearTimeout(existingSoft.timer) + this.pendingSoftLeavers.delete(ptyId) + } + const softTimer = setTimeout(() => { + this.pendingSoftLeavers.delete(ptyId) + if (!this.mobileSubscribers.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + } + }, SOFT_LEAVE_GRACE_MS) + this.pendingSoftLeavers.set(ptyId, { + clientId, + timer: softTimer, + record: { + clientId: subscriber.clientId, + viewport: subscriber.viewport, + wasResizedToPhone: subscriber.wasResizedToPhone, + previousCols: subscriber.previousCols, + previousRows: subscriber.previousRows, + subscribedAt: subscriber.subscribedAt, + lastActedAt: subscriber.lastActedAt + } + }) + + if (mode === 'auto' && wasResizedToPhone) { + const existingTimer = this.pendingRestoreTimers.get(ptyId) + if (existingTimer) { + clearTimeout(existingTimer.timer) + this.pendingRestoreTimers.delete(ptyId) + } + // Why: scheduling is conditional on the user's mobileAutoRestoreFitMs + // preference. `null` (default, "Indefinite") leaves the PTY at phone + // dims until the user clicks Restore on the desktop banner — the + // central UX promise of docs/mobile-fit-hold.md. A finite value runs + // the restore that long after the last unsubscribe. + const autoRestoreMs = this.getAutoRestoreFitMs() + if (autoRestoreMs == null) { + // Indefinite hold: the fit override persists, the SOFT_LEAVE_GRACE + // driver-state grace above still releases the input lock, and the + // banner's Restore button is the explicit return path. + } else { + // Snapshot the disconnecting subscriber's baseline NOW, before the + // timer fires. By the time the timer runs, the subscriber map has + // been deleted; resolveDesktopRestoreTarget would fall through to + // lastRendererSizes → current PTY size (which is at phone dims, + // wrong). The disconnecting subscriber's baseline is the correct + // restore target. + const fallback = this.lastRendererSizes.get(ptyId) + const restoreCols = + subscriber.previousCols ?? fallback?.cols ?? this.getTerminalSize(ptyId)?.cols ?? 80 + const restoreRows = + subscriber.previousRows ?? fallback?.rows ?? this.getTerminalSize(ptyId)?.rows ?? 24 + const timer = setTimeout(() => { + this.pendingRestoreTimers.delete(ptyId) + if (this.isMobileSubscriberActive(ptyId)) { + return + } + void this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restoreCols, + rows: restoreRows + }) + }, autoRestoreMs) + + this.pendingRestoreTimers.set(ptyId, { timer, clientId }) + } + } + // 'desktop' mode: was never resized, nothing to restore. + } + + // Why: called when mode changes via terminal.setDisplayMode. Applies the + // mode change immediately if there's an active subscriber, and emits a + // 'resized' event so the mobile client can reinitialize xterm inline. + // + // Multi-mobile: the most recent mobile actor's viewport drives the active + // phone-fit dims. The earliest-by-subscribe-time subscriber's + // previousCols/Rows drive the desktop-restore target. + async applyMobileDisplayMode(ptyId: string): Promise { + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + const inner = this.mobileSubscribers.get(ptyId) + const subscriber = inner ? this.pickMostRecentActor(inner) : null + const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null + + if (mode === 'desktop') { + // Reset wasResizedToPhone on every fitted subscriber so a future + // toggle back to auto re-issues the resize. applyLayout owns the + // actual PTY resize + override delete + renderer notify. + let anyWasResized = false + if (inner) { + for (const sub of inner.values()) { + if (sub.wasResizedToPhone) { + anyWasResized = true + sub.wasResizedToPhone = false + } + } + } + if (anyWasResized) { + const restore = this.resolveDesktopRestoreTarget(ptyId) + await this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows + }) + } else { + // No subscriber was fitted — emit a mode-change resize event so + // the mobile client still learns the toggle landed. + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: 'desktop', + reason: 'mode-change', + seq: this.layouts.get(ptyId)?.seq + }) + } + } else { + // mode === 'auto' — the only non-desktop mode after the 'phone' + // (sticky-fit) collapse. Phone-fit if the active subscriber has a + // viewport and we haven't already applied it. + if (subscriberRecord && !subscriberRecord.wasResizedToPhone) { + const viewport = subscriberRecord.viewport + if (viewport) { + await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) + return + } + } + // Why: always emit the mode change even when no resize occurred — the + // mobile client needs to learn the toggle landed even if dims didn't + // actually change. Carry the current seq (or undefined if no layout + // entry yet) so the mobile-side stale-event filter behaves correctly. + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: 'auto', + reason: 'mode-change', + seq: this.layouts.get(ptyId)?.seq + }) + } + } + + // Why: called from the pty:resize IPC handler whenever the desktop renderer + // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode + // restore). Stores the renderer-reported size so handleMobileSubscribe can use + // the actual pane geometry instead of a stale PTY size for previousCols. + // This is a passive geometry report — it does NOT call applyLayout; the + // PTY is already at the reported size. + onExternalPtyResize(ptyId: string, cols: number, rows: number): void { + // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` + // before calling here, but defend against callers that don't. + if (this.isResizeSuppressed()) { + return + } + // Why: while a mobile-fit override is in place, the desktop renderer's + // safeFit echoes pty:resize(override.cols, override.rows). Treating that + // echo as legitimate geometry would overwrite each subscriber's + // previousCols/Rows baseline with phone dims, so the next take-back + // enqueues a no-op {kind:'desktop', cols:49, rows:40} and leaves xterm + // stuck. Only filter reports that EXACTLY match the override — a fresh + // measurement from a now-visible pane (e.g. user activated a previously + // hidden tab on desktop, container went 0×0 → 1782×1195) reports + // different dims and is the right baseline to remember. + const activeOverride = this.terminalFitOverrides.get(ptyId) + if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { + return + } + this.refreshRendererGeometry(ptyId, cols, rows) + } + + // Why: pty:reportGeometry IPC sibling. The renderer calls this when a + // desktop pane container goes from 0×0 to a real size while a mobile-fit + // override is active (e.g. user activates a previously-hidden tab on + // desktop after the phone has already taken the floor). We need the + // restore-target baseline to track real desktop dims even during the + // fit period — otherwise resolveDesktopRestoreTarget falls back to the + // PTY's spawn default (typically 80×24) and Take Back leaves the + // terminal partially restored. This is a measurement-only channel: it + // refreshes lastRendererSizes and non-null subscriber baselines, never + // resizes the PTY, and bypasses both isResizeSuppressed and the + // override-echo gate by design — the renderer only fires it when it + // has just measured fresh real geometry. See docs/mobile-fit-hold.md. + recordRendererGeometry(ptyId: string, cols: number, rows: number): void { + if (cols <= 0 || rows <= 0) { + return + } + this.refreshRendererGeometry(ptyId, cols, rows) + } + + // Why: test seam — exposes lastRendererSizes for assertions about + // pty:reportGeometry / onExternalPtyResize side effects without making + // the underlying Map writable from the outside. + getLastRendererSize(ptyId: string): { cols: number; rows: number } | null { + return this.lastRendererSizes.get(ptyId) ?? null + } + + private refreshRendererGeometry(ptyId: string, cols: number, rows: number): void { + this.lastRendererSizes.set(ptyId, { cols, rows }) + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return + } + // Refresh the renderer-current size as the next-restore target on every + // subscriber that already has a non-null baseline. Subscribers with null + // baselines (joined while a peer had already phone-fitted) stay null. + for (const sub of inner.values()) { + if (sub.previousCols != null && sub.previousRows != null) { + sub.previousCols = cols + sub.previousRows = rows + } + } + } + + // Why: the pty:resize IPC handler calls this to check if the global + // suppress window is active. During this window, all desktop renderer + // pty:resize events are ignored to prevent collateral safeFit corruption. + isResizeSuppressed(): boolean { + return Date.now() < this.resizeSuppressedUntil + } + + private suppressResizesForMs(ms: number): void { + this.resizeSuppressedUntil = Date.now() + ms + } + + subscribeToTerminalResize( + ptyId: string, + listener: (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void + ): () => void { + let listeners = this.resizeListeners.get(ptyId) + if (!listeners) { + listeners = new Set() + this.resizeListeners.set(ptyId, listeners) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + this.resizeListeners.delete(ptyId) + } + } + } + + private notifyTerminalResize( + ptyId: string, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } + ): void { + const listeners = this.resizeListeners.get(ptyId) + if (!listeners) { + return + } + for (const listener of listeners) { + listener(event) + } + } + // Why: Section 7.2 — the runtime detects agent exit directly and updates // dispatch contexts immediately, rather than waiting for the coordinator's // next poll cycle. This catches agent crashes and unexpected exits within @@ -570,13 +2836,44 @@ export class OrcaRuntimeService { const worktreesById = await this.getResolvedWorktreeMap() this.assertStableReadyGraph(graphEpoch) + const resolvedWorktrees = [...worktreesById.values()] + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) + + const livePtyWorktreeIds = new Set() + for (const pty of this.ptysById.values()) { + if (pty.connected) { + livePtyWorktreeIds.add(pty.worktreeId) + } + } + const terminals: RuntimeTerminalSummary[] = [] + const ptyIdsFromLeaves = new Set() for (const leaf of this.leaves.values()) { if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { continue } + if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) { + continue + } + if (leaf.ptyId) { + ptyIdsFromLeaves.add(leaf.ptyId) + } terminals.push(this.buildTerminalSummary(leaf, worktreesById)) } + + // Why: worktree.ps can classify active worktrees from PTY records even when + // the renderer graph is missing a leaf. terminal.list needs the same fallback + // so mobile does not show a false "No terminals" create flow. + for (const pty of this.ptysById.values()) { + if (!pty.connected || ptyIdsFromLeaves.has(pty.ptyId)) { + continue + } + if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) { + continue + } + terminals.push(this.buildPtyTerminalSummary(pty, worktreesById)) + } + return { terminals: terminals.slice(0, limit), totalCount: terminals.length, @@ -623,6 +2920,15 @@ export class OrcaRuntimeService { const graphEpoch = this.captureReadyGraphEpoch() const worktreesById = await this.getResolvedWorktreeMap() this.assertStableReadyGraph(graphEpoch) + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return { + ...this.buildPtyTerminalSummary(pty.pty, worktreesById), + paneRuntimeId: -1, + ptyId: pty.pty.ptyId, + rendererGraphEpoch: this.rendererGraphEpoch + } + } const { leaf } = this.getLiveLeafForHandle(handle) const summary = this.buildTerminalSummary(leaf, worktreesById) return { @@ -634,6 +2940,11 @@ export class OrcaRuntimeService { } async readTerminal(handle: string, opts: { cursor?: number } = {}): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return this.readPtyTerminal(handle, pty.pty, opts) + } + const { leaf } = this.getLiveLeafForHandle(handle) const allLines = buildTailLines(leaf.tailBuffer, leaf.tailPartialLine) @@ -681,6 +2992,23 @@ export class OrcaRuntimeService { interrupt?: boolean } ): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_not_writable') + } + const payload = buildSendPayload(action) + if (payload === null) { + throw new Error('invalid_terminal_send') + } + await this.writeTerminalAction(pty.pty.ptyId, action, payload) + return { + handle, + accepted: true, + bytesWritten: Buffer.byteLength(payload, 'utf8') + } + } + const { leaf } = this.getLiveLeafForHandle(handle) if (!leaf.writable || !leaf.ptyId) { throw new Error('terminal_not_writable') @@ -690,31 +3018,7 @@ export class OrcaRuntimeService { throw new Error('invalid_terminal_send') } - // Why: TUI apps (Claude Code, etc.) treat a single large write as a paste - // event. If \r is included in the same write as multi-line text, the TUI - // interprets it as part of the paste rather than a discrete Enter keypress. - // Splitting the text and the trailing control characters into separate - // writes with a small delay ensures the TUI processes the paste first, - // then receives Enter as a distinct input event. - const hasText = typeof action.text === 'string' && action.text.length > 0 - const hasSuffix = action.enter || action.interrupt - if (hasText && hasSuffix) { - const textWrote = this.ptyController?.write(leaf.ptyId, action.text!) ?? false - if (!textWrote) { - throw new Error('terminal_not_writable') - } - const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') - await new Promise((resolve) => setTimeout(resolve, 500)) - const suffixWrote = this.ptyController?.write(leaf.ptyId, suffix) ?? false - if (!suffixWrote) { - throw new Error('terminal_not_writable') - } - } else { - const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') - } - } + await this.writeTerminalAction(leaf.ptyId, action, payload) return { handle, @@ -723,6 +3027,36 @@ export class OrcaRuntimeService { } } + private async writeTerminalAction( + ptyId: string, + action: { text?: string; enter?: boolean; interrupt?: boolean }, + payload: string + ): Promise { + // Why: TUI apps (Claude Code, etc.) treat a single large write as a paste + // event. Keep Enter/interrupt as a second write for both visible and + // background PTYs so CLI automation behaves the same either way. + const hasText = typeof action.text === 'string' && action.text.length > 0 + const hasSuffix = action.enter || action.interrupt + if (hasText && hasSuffix) { + const textWrote = this.ptyController?.write(ptyId, action.text!) ?? false + if (!textWrote) { + throw new Error('terminal_not_writable') + } + const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') + await new Promise((resolve) => setTimeout(resolve, 500)) + const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false + if (!suffixWrote) { + throw new Error('terminal_not_writable') + } + return + } + + const wrote = this.ptyController?.write(ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + } + async waitForTerminal( handle: string, options?: { @@ -731,6 +3065,52 @@ export class OrcaRuntimeService { } ): Promise { const condition = options?.condition ?? 'exit' + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (condition === 'exit' && !pty.pty.connected) { + return buildPtyTerminalWaitResult(handle, condition, pty.pty) + } + if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') { + return buildPtyTerminalWaitResult(handle, condition, pty.pty) + } + return await new Promise((resolve, reject) => { + const effectiveTimeoutMs = + typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 + ? options.timeoutMs + : condition === 'tui-idle' + ? TUI_IDLE_DEFAULT_TIMEOUT_MS + : 0 + const waiter: TerminalWaiter = { + handle, + condition, + resolve, + reject, + timeout: null, + pollInterval: null + } + if (effectiveTimeoutMs > 0) { + waiter.timeout = setTimeout(() => { + this.removeWaiter(waiter) + reject(new Error('timeout')) + }, effectiveTimeoutMs) + } + let waiters = this.waitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.waitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + const live = this.getLivePtyForHandle(handle) + if (!live) { + this.removeWaiter(waiter) + reject(new Error('terminal_handle_stale')) + } else if (condition === 'exit' && !live.pty.connected) { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } else if (condition === 'tui-idle' && live.pty.lastAgentStatus === 'idle') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } + }) + } const { leaf } = this.getLiveLeafForHandle(handle) if (condition === 'exit' && getTerminalState(leaf) === 'exited') { @@ -820,36 +3200,71 @@ export class OrcaRuntimeService { throw new Error('invalid_limit') } const resolvedWorktrees = await this.listResolvedWorktrees() + await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const summaries = new Map() + // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), + // matching how the renderer's fetchPRForBranch stores entries. We look up cached + // PR info so mobile clients can group worktrees by PR state without making + // expensive `gh` CLI calls. Falls back to meta.linkedPR if no cache entry exists. + const ghCache = this.store?.getGitHubCache?.() for (const worktree of resolvedWorktrees) { const meta = this.store?.getWorktreeMeta?.(worktree.id) ?? this.store?.getAllWorktreeMeta()[worktree.id] + const repo = repoById.get(worktree.repoId) + let linkedPR: { number: number; state: string } | null = null + const branch = worktree.branch.replace(/^refs\/heads\//, '') + if (repo?.path && branch && ghCache) { + const prCacheKey = `${repo.path}::${branch}` + const cached = ghCache.pr[prCacheKey] + if (cached?.data) { + linkedPR = { number: cached.data.number, state: cached.data.state } + } + } + if (!linkedPR && meta?.linkedPR != null) { + linkedPR = { number: meta.linkedPR, state: 'unknown' } + } summaries.set(worktree.id, { worktreeId: worktree.id, repoId: worktree.repoId, - repo: repoById.get(worktree.repoId)?.displayName ?? worktree.repoId, + repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, + displayName: worktree.displayName, linkedIssue: worktree.linkedIssue, + linkedPR, + isPinned: meta?.isPinned ?? false, unread: meta?.isUnread ?? false, liveTerminalCount: 0, hasAttachedPty: false, lastOutputAt: null, - preview: '' + preview: '', + status: 'inactive' }) } + const countedPtyIds = new Set() for (const leaf of this.leaves.values()) { - const summary = summaries.get(leaf.worktreeId) + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + resolvedWorktrees, + leaf.worktreeId + ) if (!summary) { continue } + if (leaf.ptyId) { + countedPtyIds.add(leaf.ptyId) + } const previousLastOutputAt = summary.lastOutputAt summary.liveTerminalCount += 1 summary.hasAttachedPty = summary.hasAttachedPty || leaf.connected summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) + summary.status = mergeWorktreeStatus( + summary.status, + getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) + ) if ( leaf.preview && (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -858,6 +3273,53 @@ export class OrcaRuntimeService { } } + for (const pty of this.ptysById.values()) { + if (!pty.connected || countedPtyIds.has(pty.ptyId)) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId( + summaries, + resolvedWorktrees, + pty.worktreeId + ) + if (!summary) { + continue + } + const previousLastOutputAt = summary.lastOutputAt + summary.liveTerminalCount += 1 + summary.hasAttachedPty = true + summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) + summary.status = mergeWorktreeStatus(summary.status, 'active') + if ( + pty.preview && + (summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) + ) { + summary.preview = pty.preview + } + } + + const session = this.store?.getWorkspaceSession?.() + for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { + if (tabs.length === 0) { + continue + } + const summary = this.getSummaryForRuntimeWorktreeId(summaries, resolvedWorktrees, worktreeId) + if (!summary) { + continue + } + // Why: desktop can show terminal tabs that are not mounted as renderer + // leaves and are not currently visible in the PTY provider list. Mobile + // still needs those worktrees to show as terminal-bearing entries. + summary.liveTerminalCount = Math.max(summary.liveTerminalCount, tabs.length) + summary.hasAttachedPty = summary.hasAttachedPty || tabs.some((tab) => tab.ptyId !== null) + for (const tab of tabs) { + summary.status = mergeWorktreeStatus( + summary.status, + getSavedTabWorktreeStatus(tab.title, tab.ptyId !== null) + ) + } + } + const sorted = [...summaries.values()].sort(compareWorktreePs) return { worktrees: sorted.slice(0, limit), @@ -940,6 +3402,19 @@ export class OrcaRuntimeService { } } + async getRepoHooks(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + const hasFile = hasHooksFile(repo.path) + const hooks = getEffectiveHooks(repo) + const setupRunPolicy = getEffectiveSetupRunPolicy(repo) + return { + hasHooksFile: hasFile, + hooks, + setupRunPolicy, + source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null + } + } + async listManagedWorktrees( repoSelector?: string, limit = DEFAULT_WORKTREE_LIST_LIMIT @@ -961,6 +3436,33 @@ export class OrcaRuntimeService { return await this.resolveWorktreeSelector(worktreeSelector) } + async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + // Why: sleep is renderer-initiated on desktop (it tears down tab state + // before killing PTYs). The notifier tells the renderer to run its own + // sleep flow so all cleanup happens in the correct order. + this.notifier?.sleepWorktree(worktree.id) + return { worktreeId: worktree.id } + } + + async activateManagedWorktree(worktreeSelector: string): Promise<{ + repoId: string + worktreeId: string + activated: boolean + }> { + this.assertGraphReady() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + if (!repo) { + throw new Error('repo_not_found') + } + + // Why: inactive worktree terminal panes are renderer-owned and may not have + // live PTYs until the desktop activates the worktree and mounts them. + this.notifier?.activateWorktree(repo.id, worktree.id) + return { repoId: repo.id, worktreeId: worktree.id, activated: true } + } + async createManagedWorktree(args: { repoSelector: string name: string @@ -968,6 +3470,9 @@ export class OrcaRuntimeService { linkedIssue?: number | null comment?: string runHooks?: boolean + activate?: boolean + setupDecision?: 'run' | 'skip' | 'inherit' + startup?: WorktreeStartupLaunch }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -1022,10 +3527,18 @@ export class OrcaRuntimeService { } const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + // Why (§3.3 Lifecycle): route through the shared fetch cache so back-to-back + // CLI creates on the same repo don't each pay the round-trip, and so a + // subsequent dispatch probe within the 30s window reuses this result. The + // helper swallows rejection (log-and-proceed) so a DNS hiccup never wedges + // future creates and CLI creation stays usable offline — same intent as + // the previous try/catch around gitExecFileSync. try { - gitExecFileSync(['fetch', remote], { cwd: repo.path }) + await this.fetchRemoteWithCache(repo.path, remote) } catch { - // Why: matching the editor behavior keeps CLI creation usable offline. + // Why: belt-and-suspenders. fetchRemoteWithCache already logs and does + // not throw; the outer try/catch guarantees create-path tolerance even + // if future refactors change that contract. } await addWorktree( @@ -1042,11 +3555,18 @@ export class OrcaRuntimeService { } const worktreeId = `${repo.id}::${created.path}` + const now = Date.now() const meta = this.store.setWorktreeMeta(worktreeId, { - lastActivityAt: Date.now(), + lastActivityAt: now, + // See createRemoteWorktree: createdAt grants the new worktree a grace + // window in Recent sort so ambient PTY bumps in OTHER worktrees can't + // push it down before the user has had a chance to notice it. Smart-sort + // uses max(lastActivityAt, createdAt + CREATE_GRACE_MS). + createdAt: now, ...(shouldSetDisplayName(requestedName, branchName, sanitizedName) ? { displayName: requestedName } : {}), + baseRef: baseBranch, ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}) }) @@ -1058,7 +3578,13 @@ export class OrcaRuntimeService { // against. Trust is granted by the direct CLI invocation (`--run-hooks`), // so loading the setup hook from the created worktree is intentional here. const hooks = getEffectiveHooks(repo, worktreePath) - if (hooks?.scripts.setup && args.runHooks === true) { + // Why: setupDecision lets mobile/CLI callers control whether the setup + // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) + // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps + // to 'run' for backwards compatibility with the desktop create flow. + const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) + if (shouldRunSetup && hooks?.scripts.setup) { if (this.authoritativeWindowId !== null) { try { // Why: CLI-created worktrees must use the same runner-script path as the @@ -1085,12 +3611,6 @@ export class OrcaRuntimeService { console.warn(`[hooks] ${warning}`) } - this.notifier?.worktreesChanged(repo.id) - // Why: the editor currently creates the first Orca-managed terminal as a - // renderer-side consequence of activating a worktree. CLI-created - // worktrees must trigger that same activation path or they will exist on - // disk without becoming the active workspace in the UI. - this.notifier?.activateWorktree(repo.id, worktree.id, setup) this.invalidateResolvedWorktreeCache() // Why: the filesystem-auth layer maintains a separate cache of registered // worktree roots used by git IPC handlers (branchCompare, diff, status, etc.) @@ -1098,6 +3618,38 @@ export class OrcaRuntimeService { // are not recognized and all git operations fail with "Access denied: // unknown repository or worktree path". invalidateAuthorizedRootsCache() + this.notifier?.worktreesChanged(repo.id) + const shouldActivate = args.activate === true || args.runHooks === true || Boolean(args.startup) + if (shouldActivate) { + // Why: plain CLI creates should not steal the user's current workspace. + // Startup launches still use renderer activation because they are an + // explicit request to start visible work in the new worktree. + if (args.startup) { + this.notifier?.activateWorktree(repo.id, worktree.id, setup, args.startup) + } else { + this.notifier?.activateWorktree(repo.id, worktree.id, setup) + } + } else if (this.ptyController?.spawn) { + try { + await this.createTerminal(`path:${worktree.path}`) + if (setup) { + await this.createTerminal(`path:${worktree.path}`, { + title: 'Setup', + command: buildSetupRunnerCommand( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ), + env: setup.envVars + }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the initial terminal for ${worktreePath}: ${message}` + : `Failed to create the initial terminal for ${worktreePath}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } return { worktree, ...(setup ? { setup } : {}), @@ -1105,12 +3657,346 @@ export class OrcaRuntimeService { } } + /** + * Fetch `remote` in `repoPath`, sharing the 30s freshness window + in-flight + * serialization with all other callers (renderer-create path, CLI create, + * dispatch drift probe). Never rejects — callers log-and-proceed on offline + * failures (§3.3 Lifecycle). + * + * Why a shared cache on the runtime instead of module-scoped: §7.1 relies on + * one cache for BOTH the renderer create path and `probeWorktreeDrift`. A + * dispatch tick that reuses a just-completed create-path fetch is the + * primary telemetry target; splitting the cache by call-site would double + * the fetch load on warm repos. + */ + async getCanonicalFetchKey(repoPath: string, remote: string): Promise { + const cacheKey = `${repoPath}::${remote}` + const cached = this.canonicalFetchKeyCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + let resolved = cacheKey + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd: repoPath } + ) + const commonDir = stdout.trim() + if (commonDir) { + resolved = `${commonDir}::${remote}` + } + } catch { + // Fall through to the caller-provided path. The fetch still runs from + // repoPath; this key only controls cache sharing. + } + this.canonicalFetchKeyCache.set(cacheKey, resolved) + return resolved + } + + async isRemoteFetchFresh(repoPath: string, remote: string): Promise { + const key = await this.getCanonicalFetchKey(repoPath, remote) + const lastAt = this.fetchLastCompletedAt.get(key) + return lastAt !== undefined && Date.now() - lastAt < FETCH_FRESHNESS_MS + } + + async getOrStartRemoteFetch(repoPath: string, remote: string): Promise { + const key = await this.getCanonicalFetchKey(repoPath, remote) + const lastAt = this.fetchLastCompletedAt.get(key) + if (lastAt !== undefined && Date.now() - lastAt < FETCH_FRESHNESS_MS) { + // Why: freshness window hit — skip the fetch entirely. Do NOT reuse any + // in-flight promise here; the timestamp is only written on success, so + // hitting this branch means a previous fetch did succeed recently. + return { ok: true } + } + + const existing = this.fetchInflight.get(key) + if (existing) { + // Why: genuine serialization (not check-then-set). Two callers racing + // on the same repo+remote share the single underlying `git fetch`. + return existing + } + + const promise = gitExecFileAsync(['fetch', remote], { cwd: repoPath }) + .then((): RemoteFetchResult => { + // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection + // would make the freshness cache lie about the last known remote state. + this.fetchLastCompletedAt.set(key, Date.now()) + return { ok: true } + }) + .catch((err): RemoteFetchResult => { + // Why: swallow here so awaiters don't throw at the await site. Outer + // create/dispatch paths are already tolerant of offline fetch failure; + // this is the behavioral contract of this helper. + console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err) + return { ok: false, errorKind: 'git_error' } + }) + .finally(() => { + // Why (§3.3 Lifecycle): evict on BOTH success and rejection. A + // rejected entry that survived in the Map would wedge every future + // create on this repo until Orca restarted (the F2 bug §3.3 pins). + this.fetchInflight.delete(key) + }) + + this.fetchInflight.set(key, promise) + return promise + } + + async fetchRemoteWithCache(repoPath: string, remote: string): Promise { + await this.getOrStartRemoteFetch(repoPath, remote) + } + + async resolveRemoteTrackingBase( + repoPath: string, + baseBranch: string + ): Promise { + let remotes: string[] + try { + const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath }) + remotes = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return null + } + + const remote = remotes + .filter((candidate) => baseBranch.startsWith(`${candidate}/`)) + .sort((a, b) => b.length - a.length)[0] + if (!remote) { + return null + } + const branch = baseBranch.slice(remote.length + 1) + if (!branch) { + return null + } + return { + remote, + branch, + ref: `refs/remotes/${remote}/${branch}`, + base: baseBranch + } + } + + async hasRemoteTrackingRef(repoPath: string, base: RemoteTrackingBase): Promise { + try { + await gitExecFileAsync(['rev-parse', '--verify', `${base.ref}^{commit}`], { cwd: repoPath }) + return true + } catch { + return false + } + } + + recordOptimisticReconcileToken(worktreeId: string): string { + const token = randomUUID() + this.optimisticReconcileTokens.set(worktreeId, token) + return token + } + + clearOptimisticReconcileToken(worktreeId: string): void { + this.optimisticReconcileTokens.delete(worktreeId) + } + + emitWorktreeBaseStatus(event: WorktreeBaseStatusEvent): void { + this.notifier?.worktreeBaseStatus?.(event) + } + + async reconcileWorktreeBaseStatus(args: { + repoId: string + repoPath: string + worktreeId: string + base: RemoteTrackingBase + branchName: string + createdBaseSha: string + token: string + fetchPromise: Promise + }): Promise { + const stillCurrent = (): boolean => + this.optimisticReconcileTokens.get(args.worktreeId) === args.token + const emit = (event: Omit): void => { + if (!stillCurrent()) { + return + } + this.notifier?.worktreeBaseStatus?.({ + repoId: args.repoId, + worktreeId: args.worktreeId, + base: args.base.base, + remote: args.base.remote, + ...event + }) + } + const resolvePublishRemote = async (): Promise => { + // Why: repos whose canonical publish remote is named differently (e.g. + // `upstream`, a forked `myfork`, or any non-`origin` configuration — + // including multi-segment names like `foo/bar` that this PR's resolver + // explicitly supports) would otherwise silently skip the conflict + // signal. Resolve from git config in priority order: + // 1) branch..pushRemote (explicit per-branch override) + // 2) remote.pushDefault (workspace-wide override) + // 3) branch..remote (tracked remote) + // 4) the base ref's own remote (matches resolveRemoteTrackingBase) + // 5) `origin` as a final fallback. + const tryConfig = async (key: string): Promise => { + try { + const { stdout } = await gitExecFileAsync(['config', '--get', key], { + cwd: args.repoPath + }) + const value = stdout.trim() + return value || null + } catch { + return null + } + } + return ( + (await tryConfig(`branch.${args.branchName}.pushRemote`)) ?? + (await tryConfig('remote.pushDefault')) ?? + (await tryConfig(`branch.${args.branchName}.remote`)) ?? + args.base.remote ?? + 'origin' + ) + } + const checkPublishRemoteConflict = async (): Promise => { + const publishRemote = await resolvePublishRemote() + try { + if (publishRemote !== args.base.remote) { + const result = await this.getOrStartRemoteFetch(args.repoPath, publishRemote) + if (!result.ok) { + return + } + } + await gitExecFileAsync( + ['rev-parse', '--verify', `refs/remotes/${publishRemote}/${args.branchName}^{commit}`], + { cwd: args.repoPath } + ) + if (stillCurrent()) { + this.notifier?.worktreeRemoteBranchConflict?.({ + repoId: args.repoId, + worktreeId: args.worktreeId, + remote: publishRemote, + branchName: args.branchName + }) + } + } catch { + // No publish-remote conflict is the common case; stay quiet. + } + } + + try { + const fetchResult = await args.fetchPromise + if (!stillCurrent()) { + return + } + if (!fetchResult.ok) { + emit({ status: 'unknown' }) + return + } + + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', `${args.base.ref}^{commit}`], + { cwd: args.repoPath } + ) + const postFetchSha = stdout.trim() + if (postFetchSha === args.createdBaseSha) { + emit({ status: 'current' }) + await checkPublishRemoteConflict() + return + } + + try { + await gitExecFileAsync(['merge-base', '--is-ancestor', args.createdBaseSha, postFetchSha], { + cwd: args.repoPath + }) + } catch { + emit({ status: 'base_changed' }) + await checkPublishRemoteConflict() + return + } + + const { stdout: countStdout } = await gitExecFileAsync( + ['rev-list', '--count', `${args.createdBaseSha}..${postFetchSha}`], + { cwd: args.repoPath } + ) + const behind = Number(countStdout.trim()) + if (!Number.isFinite(behind) || behind <= 0) { + emit({ status: 'current' }) + await checkPublishRemoteConflict() + return + } + const { stdout: logStdout } = await gitExecFileAsync( + ['log', '--format=%s', '-n', '5', `${args.createdBaseSha}..${postFetchSha}`], + { cwd: args.repoPath } + ) + emit({ + status: 'drift', + behind, + recentSubjects: logStdout.split('\n').filter((line) => line.trim().length > 0) + }) + await checkPublishRemoteConflict() + } catch (err) { + console.warn(`[worktree-base-status] reconcile failed for ${args.worktreeId}:`, err) + emit({ status: 'unknown' }) + } finally { + // Why: reconcile is one-shot; clear the token so long-lived sessions + // that create many worktrees without removing them don't grow the + // optimisticReconcileTokens map monotonically. Removal still no-ops + // because the entry is already gone. + if (this.optimisticReconcileTokens.get(args.worktreeId) === args.token) { + this.optimisticReconcileTokens.delete(args.worktreeId) + } + } + } + + /** + * Probe how far the worktree's HEAD is behind its tracking remote. Returns + * null when the probe cannot establish a signal (no default base ref, or + * git failure). Dispatch treats null as "unknown — proceed" (§3.1); only + * knowing-and-stale refuses. + */ + async probeWorktreeDrift(worktreeSelector: string): Promise<{ + base: string + behind: number + recentSubjects: string[] + } | null> { + const wt = await this.resolveWorktreeSelector(worktreeSelector) + if (!this.store) { + return null + } + const repo = this.store.getRepos().find((r) => r.id === wt.repoId) + if (!repo) { + return null + } + const meta = this.store.getWorktreeMeta(wt.id) + const base = + meta?.baseRef || meta?.sparseBaseRef || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + if (!base) { + // Why: brand-new repo with no remote primary — nothing to compare + // against, so there's no meaningful drift to report. Dispatch should + // not block on a probe that cannot form an opinion. + return null + } + const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, base) + if (!remoteTrackingBase) { + return null + } + const remote = remoteTrackingBase.remote + // Why: fetch failures are non-fatal; we proceed with whatever the + // last-known remote ref points at. `fetchRemoteWithCache` never throws. + await this.fetchRemoteWithCache(repo.path, remote) + const drift = getRemoteDrift(wt.path, 'HEAD', base) + if (!drift) { + return null + } + const recentSubjects = getRecentDriftSubjects(wt.path, 'HEAD', base, DRIFT_PROBE_SUBJECT_LIMIT) + return { base, behind: drift.behind, recentSubjects } + } + async updateManagedWorktreeMeta( worktreeSelector: string, updates: { displayName?: string linkedIssue?: number | null comment?: string + isPinned?: boolean } ) { if (!this.store) { @@ -1120,7 +4006,8 @@ export class OrcaRuntimeService { const meta = this.store.setWorktreeMeta(worktree.id, { ...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}), ...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}), - ...(updates.comment !== undefined ? { comment: updates.comment } : {}) + ...(updates.comment !== undefined ? { comment: updates.comment } : {}), + ...(updates.isPinned !== undefined ? { isPinned: updates.isPinned } : {}) }) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. @@ -1200,6 +4087,7 @@ export class OrcaRuntimeService { // list` continues to show the stale entry and the branch it had checked out // remains locked — other worktrees cannot check it out. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) + this.clearOptimisticReconcileToken(worktree.id) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() @@ -1211,6 +4099,7 @@ export class OrcaRuntimeService { throw new Error(formatWorktreeRemovalError(error, worktree.path, force)) } + this.clearOptimisticReconcileToken(worktree.id) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() @@ -1222,6 +4111,17 @@ export class OrcaRuntimeService { async renameTerminal(handle: string, title: string | null): Promise { this.assertGraphReady() + const pty = this.getLivePtyForHandle(handle) + if (pty) { + pty.pty.title = title + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === pty.pty.ptyId) { + this.notifier?.renameTerminal(leaf.tabId, title) + return { handle, tabId: leaf.tabId, title } + } + } + return { handle, tabId: pty.record.tabId, title } + } const { leaf } = this.getLiveLeafForHandle(handle) this.notifier?.renameTerminal(leaf.tabId, title) return { handle, tabId: leaf.tabId, title } @@ -1229,8 +4129,53 @@ export class OrcaRuntimeService { async createTerminal( worktreeSelector?: string, - opts: { command?: string; title?: string } = {} + opts: { command?: string; env?: Record; title?: string; focus?: boolean } = {} ): Promise { + if (opts.focus !== true) { + if (!worktreeSelector) { + throw new Error('MISSING_WORKTREE') + } + if (!this.ptyController?.spawn) { + throw new Error('runtime_unavailable') + } + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + const preAllocatedHandle = this.createPreAllocatedTerminalHandle() + const result = await this.ptyController.spawn({ + cols: 120, + rows: 40, + cwd: worktree.path, + command: opts.command, + env: opts.env, + connectionId: repo?.connectionId ?? null, + worktreeId: worktree.id, + preAllocatedHandle + }) + this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + this.registerPty(result.id, worktree.id) + const pty = this.getOrCreatePtyWorktreeRecord(result.id) + if (pty) { + pty.title = opts.title ?? null + } + const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle + let surface: RuntimeTerminalCreate['surface'] = 'background' + if (this.notifier?.revealTerminalSession) { + try { + // Why: after the PTY is spawned, renderer tab adoption is best-effort; + // failing here must not strand a live process without returning a handle. + await this.notifier.revealTerminalSession(worktree.id, { + ptyId: result.id, + title: opts.title ?? null, + activate: false + }) + surface = 'visible' + } catch (err) { + console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) + } + } + return { handle, worktreeId: worktree.id, title: opts.title ?? null, surface } + } + this.assertGraphReady() const win = this.getAuthoritativeWindow() // Why: mirrors browserTabCreate — when no worktree is specified, pass @@ -1277,7 +4222,7 @@ export class OrcaRuntimeService { // populates this.leaves may not have arrived yet. Wait for the leaf to // appear so we can return a valid handle the caller can use right away. const handle = await this.waitForTerminalHandle(reply.tabId) - return { handle, worktreeId: worktreeId ?? '', title: reply.title } + return { handle, worktreeId: worktreeId ?? '', title: reply.title, surface: 'visible' } } private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise { @@ -1313,6 +4258,54 @@ export class OrcaRuntimeService { }) } + // Why: mobile clients may subscribe before the PTY spawns (the left pane + // of a new workspace). Instead of bailing with a bare scrollback+end, + // wait for the PTY to appear so the subscribe can proceed with phone-fit. + waitForLeafPtyId(handle: string, timeoutMs = 10_000): Promise { + const leaf = this.resolveLeafForHandle(handle) + if (leaf?.ptyId) { + return Promise.resolve(leaf.ptyId) + } + + // Why: when the ptyId changes from null to a real value, the old handle + // is invalidated (deleted from this.handles). Capture the tabId+leafId + // now so we can look up the leaf directly even after handle invalidation. + const record = this.handles.get(handle) + const savedTabId = record?.tabId ?? null + const savedLeafId = record?.leafId ?? null + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + reject(new Error('Timed out waiting for PTY to spawn')) + }, timeoutMs) + + const check = (): void => { + // Try the handle first (works if handle wasn't invalidated yet) + let ptyId = this.resolveLeafForHandle(handle)?.ptyId + // Why: when ptyId transitions null→real, issueHandle invalidates the + // old handle. Fall back to direct leaf lookup by the saved coordinates. + if (!ptyId && savedTabId && savedLeafId) { + const directLeaf = this.leaves.get(this.getLeafKey(savedTabId, savedLeafId)) + ptyId = directLeaf?.ptyId ?? null + } + if (ptyId) { + clearTimeout(timer) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + resolve(ptyId) + } + } + this.graphSyncCallbacks.push(check) + check() + }) + } + // Why: a leaf appears in the graph before its PTY spawns. If we issue a // handle while ptyId is null, the next graph sync after PTY spawn will // change ptyId and invalidate the handle. Wait for a connected PTY so @@ -1338,6 +4331,21 @@ export class OrcaRuntimeService { async focusTerminal(handle: string): Promise { this.assertGraphReady() + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_exited') + } + const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { + ptyId: pty.pty.ptyId, + title: pty.pty.title ?? pty.pty.lastOscTitle + }) + return { + handle, + tabId: revealed?.tabId ?? pty.record.tabId, + worktreeId: pty.pty.worktreeId + } + } const { leaf } = this.getLiveLeafForHandle(handle) this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId) return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId } @@ -1345,6 +4353,11 @@ export class OrcaRuntimeService { async closeTerminal(handle: string): Promise { this.assertGraphReady() + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false + return { handle, tabId: pty.record.tabId, ptyKilled } + } const { leaf } = this.getLiveLeafForHandle(handle) let ptyKilled = false if (leaf.ptyId) { @@ -1446,6 +4459,11 @@ export class OrcaRuntimeService { ptyIds.add(leaf.ptyId) } } + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === worktree.id && pty.connected) { + ptyIds.add(pty.ptyId) + } + } let stopped = 0 for (const ptyId of ptyIds) { @@ -1471,6 +4489,10 @@ export class OrcaRuntimeService { this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() + // Why: handleByPtyId maps ptyId → pre-allocated CLI handle (ORCA_TERMINAL_HANDLE). + // These must survive renderer reloads so CLI agents can keep controlling the + // same terminal across graph rebuilds — adoptPreAllocatedHandle re-links + // them when the new graph arrives. this.rejectAllWaiters('terminal_handle_stale') this.refreshWritableFlags() } @@ -1499,6 +4521,8 @@ export class OrcaRuntimeService { this.leaves.clear() this.handles.clear() this.handleByLeafKey.clear() + // Why: same as markRendererReloading — pre-allocated CLI handles must + // survive graph unavailability so they can be re-adopted on reconnect. this.rejectAllWaiters('terminal_handle_stale') } @@ -1639,6 +4663,112 @@ export class OrcaRuntimeService { this.resolvedWorktreeCache = null } + private recordPtyWorktree( + ptyId: string, + worktreeId: string, + state: Partial> = {} + ): RuntimePtyWorktreeRecord { + let pty = this.ptysById.get(ptyId) + if (!pty) { + pty = { + ptyId, + worktreeId, + connected: state.connected ?? true, + lastExitCode: null, + lastAgentStatus: null, + lastOscTitle: null, + title: null, + lastOutputAt: state.lastOutputAt ?? null, + tailBuffer: [], + tailPartialLine: '', + tailTruncated: false, + tailLinesTotal: 0, + preview: state.preview ?? '' + } + this.ptysById.set(ptyId, pty) + return pty + } + + pty.worktreeId = worktreeId + if (state.connected !== undefined) { + pty.connected = state.connected + } + if (state.lastOutputAt !== undefined) { + pty.lastOutputAt = maxTimestamp(pty.lastOutputAt, state.lastOutputAt) + } + if (state.preview !== undefined && state.preview.length > 0) { + pty.preview = state.preview + } + return pty + } + + private getOrCreatePtyWorktreeRecord(ptyId: string): RuntimePtyWorktreeRecord | null { + const existing = this.ptysById.get(ptyId) + if (existing) { + return existing + } + const inferredWorktreeId = inferWorktreeIdFromPtyId(ptyId) + if (!inferredWorktreeId) { + return null + } + // Why: daemon-backed PTY session IDs are prefixed with the worktree ID so + // mobile summaries survive renderer graph gaps and Electron reloads. + return this.recordPtyWorktree(ptyId, inferredWorktreeId) + } + + private async refreshPtyWorktreeRecordsFromController( + resolvedWorktrees: ResolvedWorktree[] + ): Promise { + if (!this.ptyController?.listProcesses) { + return + } + const sessions = await this.ptyController.listProcesses().catch(() => []) + const livePtyIds = new Set(sessions.map((session) => session.id)) + for (const session of sessions) { + const worktreeId = + inferWorktreeIdFromPtyId(session.id) ?? + findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) + if (worktreeId) { + this.recordPtyWorktree(session.id, worktreeId, { connected: true }) + } + } + for (const pty of this.ptysById.values()) { + if (!livePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { + pty.connected = false + } + } + } + + private leafExistsForPty(ptyId: string): boolean { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === ptyId) { + return true + } + } + return false + } + + private getSummaryForRuntimeWorktreeId( + summaries: Map, + resolvedWorktrees: ResolvedWorktree[], + runtimeWorktreeId: string + ): RuntimeWorktreePsSummary | null { + const exact = summaries.get(runtimeWorktreeId) + if (exact) { + return exact + } + const parsed = parseRuntimeWorktreeId(runtimeWorktreeId) + if (!parsed) { + return null + } + const resolved = resolvedWorktrees.find( + (worktree) => + worktree.repoId === parsed.repoId && + areWorktreePathsEqual(worktree.path, parsed.worktreePath) + ) + return resolved ? (summaries.get(resolved.id) ?? null) : null + } + private buildTerminalSummary( leaf: RuntimeLeafRecord, worktreesById: Map @@ -1733,7 +4863,7 @@ export class OrcaRuntimeService { waitForMessage( handle: string, - options?: { typeFilter?: string[]; timeoutMs?: number } + options?: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal } ): Promise { return new Promise((resolve) => { const timeoutMs = options?.timeoutMs ?? MESSAGE_WAIT_DEFAULT_TIMEOUT_MS @@ -1745,7 +4875,27 @@ export class OrcaRuntimeService { timeout: null } + // Why: if the caller aborts (socket closed on the RPC side — see design + // doc §3.1 counter-lifecycle), resolve immediately so the long-poll slot + // is released instead of counting down the full timeoutMs with a dead + // client on the other end. + const signal = options?.signal + const onAbort = (): void => { + this.removeMessageWaiter(waiter) + resolve() + } + if (signal) { + if (signal.aborted) { + resolve() + return + } + signal.addEventListener('abort', onAbort, { once: true }) + } + waiter.timeout = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', onAbort) + } this.removeMessageWaiter(waiter) resolve() }, timeoutMs) @@ -1778,6 +4928,27 @@ export class OrcaRuntimeService { } } + private buildPtyTerminalSummary( + pty: RuntimePtyWorktreeRecord, + worktreesById: Map + ): RuntimeTerminalSummary { + const worktree = worktreesById.get(pty.worktreeId) + + return { + handle: this.issuePtyHandle(pty), + worktreeId: pty.worktreeId, + worktreePath: worktree?.path ?? '', + branch: worktree?.branch ?? '', + tabId: `pty:${pty.ptyId}`, + leafId: `pty:${pty.ptyId}`, + title: pty.title ?? pty.lastOscTitle, + connected: pty.connected, + writable: pty.connected, + lastOutputAt: pty.lastOutputAt, + preview: pty.preview + } + } + private getLiveLeafForHandle(handle: string): { record: TerminalHandleRecord leaf: RuntimeLeafRecord @@ -1798,6 +4969,69 @@ export class OrcaRuntimeService { return { record, leaf } } + private getLivePtyForHandle(handle: string): { + record: TerminalHandleRecord + pty: RuntimePtyWorktreeRecord + } | null { + let record = this.handles.get(handle) + if (!record) { + const ptyId = [...this.handleByPtyId.entries()].find( + ([, mappedHandle]) => mappedHandle === handle + )?.[0] + const pty = ptyId ? this.ptysById.get(ptyId) : null + if (pty) { + // Why: graph reload/unavailability clears renderer handle records, but + // runtime-owned PTY handles remain the caller's control identity. + this.issuePtyHandle(pty) + record = this.handles.get(handle) + } + } + if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) { + return null + } + if (!record.ptyId) { + return null + } + const pty = this.ptysById.get(record.ptyId) + if (!pty || pty.ptyId !== record.ptyId) { + return null + } + // Why: renderer adoption can race with CLI reads. If this synthetic PTY + // handle is valid, keep ptyId -> handle populated so summaries do not mint + // a second handle for the same terminal. + this.handleByPtyId.set(record.ptyId, handle) + return { record, pty } + } + + private readPtyTerminal( + handle: string, + pty: RuntimePtyWorktreeRecord, + opts: { cursor?: number } = {} + ): RuntimeTerminalRead { + const allLines = buildTailLines(pty.tailBuffer, pty.tailPartialLine) + + let tail: string[] + let truncated: boolean + + if (typeof opts.cursor === 'number' && opts.cursor >= 0) { + const bufferStart = pty.tailLinesTotal - pty.tailBuffer.length + const sliceFrom = Math.max(0, opts.cursor - bufferStart) + tail = pty.tailBuffer.slice(sliceFrom) + truncated = opts.cursor < bufferStart + } else { + tail = allLines + truncated = pty.tailTruncated + } + + return { + handle, + status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', + tail, + truncated, + nextCursor: String(pty.tailLinesTotal) + } + } + private issueHandle(leaf: RuntimeLeafRecord): string { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existingHandle = this.handleByLeafKey.get(leafKey) @@ -1854,6 +5088,50 @@ export class OrcaRuntimeService { return preAllocated } + private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = + this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) + if (existingHandle) { + const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.runtimeId === this.runtimeId && + existingRecord.ptyId === pty.ptyId + ) { + this.handleByPtyId.set(pty.ptyId, existingHandle) + return existingHandle + } + } + + const handle = existingHandle ?? `term_${randomUUID()}` + const syntheticId = `pty:${pty.ptyId}` + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + + private findHandleForPtyRecord(ptyId: string): string | null { + for (const [handle, record] of this.handles) { + if ( + record.runtimeId === this.runtimeId && + record.ptyId === ptyId && + record.tabId.startsWith('pty:') + ) { + return handle + } + } + return null + } + private refreshWritableFlags(): void { for (const leaf of this.leaves.values()) { leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null @@ -1918,6 +5196,41 @@ export class OrcaRuntimeService { } } + private resolvePtyExitWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { + const handle = this.handleByPtyId.get(ptyId) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'exit') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'exit', pty)) + } else { + this.removeWaiter(waiter) + waiter.reject(new Error('terminal_exited')) + } + } + } + + private resolvePtyTuiIdleWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { + const handle = this.handleByPtyId.get(ptyId) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'tui-idle') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty)) + } + } + } + // Why: OSC title detection via onPtyData is the primary signal for tui-idle, // but daemon-hosted terminals don't flow PTY data through the runtime, and // some agents don't emit recognized titles on startup. This fallback polls @@ -2002,8 +5315,15 @@ export class OrcaRuntimeService { // Why: Claude Code treats large single PTY writes as paste events and // swallows a \r included in the same write. Send Enter separately after - // a delay so the agent processes the pasted message first. Mark messages - // as read only after \r is confirmed, so failed deliveries stay queued. + // a delay so the agent processes the pasted message first. Stamp + // `delivered_at` only after \r is confirmed, so failed deliveries stay + // queued. + // + // Important (design doc §3.2, feedback #2): we stamp `delivered_at` here + // instead of flipping `read`. `read` is reserved for "a check-caller + // consumed this message." Flipping `read` on push-on-idle would hide the + // message from the coordinator's next `check --unread`, which is the + // exact bug feedback #2 reported. The two bits must stay independent. const ptyId = leaf.ptyId setTimeout(() => { try { @@ -2012,11 +5332,12 @@ export class OrcaRuntimeService { } const submitted = this.ptyController?.write(ptyId, '\r') ?? false if (submitted) { - this._orchestrationDb?.markAsRead(unread.map((m) => m.id)) + this._orchestrationDb?.markAsDelivered(unread.map((m) => m.id)) } } catch { - // Terminal may have closed during the delay — messages stay unread - // and will be re-delivered on the next idle transition. + // Terminal may have closed during the delay — messages stay queued + // (delivered_at still NULL) and will be re-delivered on the next + // idle transition. } }, 500) } @@ -2168,6 +5489,33 @@ export class OrcaRuntimeService { } } + // Why: `tabSwitch` only flips the bridge's `activeWebContentsId` — it + // does not surface the browser pane in the renderer. Without --focus, the + // switch is invisible to the user. With --focus, we send a dedicated IPC + // so the renderer can update its per-worktree active-tab state. + // + // Why this IPC carries `worktreeId` instead of letting the renderer + // dispatch `setActiveWorktree`: multiple agents drive browsers in parallel + // worktrees. A global focus call from agent X would steal the user's + // screen from agent Y's worktree. The renderer-side handler + // (focusBrowserTabInWorktree) updates per-worktree state unconditionally + // and only flips globals when the user is already on the targeted + // worktree. Cross-worktree --focus calls pre-stage silently. + private notifyRendererBrowserPaneFocus( + worktreeId: string | undefined, + browserPageId: string + ): void { + try { + const win = this.getAuthoritativeWindow() + win.webContents.send('browser:pane-focus', { + worktreeId: worktreeId ?? null, + browserPageId + }) + } catch { + // Window may not exist during shutdown + } + } + async browserSnapshot(params: BrowserCommandTargetParams): Promise { const target = await this.resolveBrowserCommandTarget(params) return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId) @@ -2303,20 +5651,46 @@ export class OrcaRuntimeService { async browserTabList(params: { worktree?: string }): Promise { const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - return this.requireAgentBrowserBridge().tabList(worktreeId) + const result = this.requireAgentBrowserBridge().tabList(worktreeId) + return { + tabs: result.tabs.map((tab) => this.enrichBrowserTabInfo(tab)) + } + } + + async browserTabShow(params: { page: string; worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + return { tab: this.describeBrowserTab(params.page, worktreeId) } + } + + async browserTabCurrent(params: { worktree?: string }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const browserPageId = this.requireAgentBrowserBridge().getActivePageId(worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + return { tab: this.describeBrowserTab(browserPageId, worktreeId) } } async browserTabSwitch( params: { index?: number + focus?: boolean } & BrowserCommandTargetParams ): Promise { const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().tabSwitch( - params.index, - target.worktreeId, - target.browserPageId - ) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.tabSwitch(params.index, target.worktreeId, target.browserPageId) + if (params.focus) { + // Why: prefer the explicit --worktree the caller passed; fall back to + // the bridge's owning-worktree map for the just-switched tab. The + // owning worktree is what the renderer needs to scope the focus to. + // The renderer NEVER yanks the user across worktrees on this signal + // (see focusBrowserTabInWorktree). + const worktreeId = + target.worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined + this.notifyRendererBrowserPaneFocus(worktreeId, result.browserPageId) + } + return result } async browserHover( @@ -2955,51 +6329,17 @@ export class OrcaRuntimeService { async browserTabCreate(params: { url?: string worktree?: string + profileId?: string }): Promise<{ browserPageId: string }> { - const win = this.getAuthoritativeWindow() - const requestId = randomUUID() const url = params.url ?? 'about:blank' - - // Why: the renderer's Zustand store keys browser tabs by worktreeId in - // "repoId::path" format. The CLI sends a selector (e.g. "path:/Users/..."). - // Resolve it here so the renderer receives the store-compatible ID. const worktreeId = params.worktree ? (await this.resolveWorktreeSelector(params.worktree)).id : undefined - - // Why: browser webviews only mount when their worktree is active in the UI. - // Switch to it before creating the tab so the webview attaches immediately. - if (worktreeId) { - await this.ensureBrowserWorktreeActive(worktreeId) - } - - // Why: tab creation is a renderer-side Zustand store operation. The main process - // sends a request, the renderer creates the tab and replies with the workspace ID - // (which is the browserPageId used by registerGuest and the bridge). - const browserPageId = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCreateReply', handler) - reject(new Error('Tab creation timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; browserPageId?: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCreateReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve(reply.browserPageId!) - } - } - ipcMain.on('browser:tabCreateReply', handler) - win.webContents.send('browser:requestTabCreate', { requestId, url, worktreeId }) - }) + const { browserPageId } = await this.createBrowserTabInRenderer( + url, + worktreeId, + params.profileId + ) // Why: the renderer creates the Zustand tab immediately, but the webview must // mount and fire dom-ready before registerGuest runs. Waiting here ensures the @@ -3040,6 +6380,163 @@ export class OrcaRuntimeService { return { browserPageId } } + async browserTabSetProfile( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const browserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + // Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered. + const profile = + browserSessionRegistry.getProfile(params.profileId) ?? + (params.profileId === 'default' ? browserSessionRegistry.getDefaultProfile() : null) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + + // Why: short-circuit no-op switches so the renderer doesn't tear down and + // remount the webview when the tab is already on the requested profile. + const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' + if (currentProfileId === profile.id) { + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + const win = this.getAuthoritativeWindow() + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabSetProfileReply', handler) + reject(new Error('Tab profile update timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabSetProfileReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve() + } + } + ipcMain.on('browser:tabSetProfileReply', handler) + win.webContents.send('browser:requestTabSetProfile', { + requestId, + browserPageId, + profileId: profile.id + }) + }) + + // Why: the renderer destroys the old webview and remounts on the new + // partition. Wait for the re-register so a follow-up tab list + // --show-profile reads the updated sessionProfileId from BrowserManager + // instead of stale data, and so subsequent CLI ops (snapshot, click, etc.) + // hit a guest that's already attached. + try { + await waitForTabRegistration(browserPageId) + } catch { + // Best-effort: re-register won't fire if the worktree is hidden. The + // store already reflects the new profile; downstream commands retry + // once the pane re-mounts. + } + + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserTabProfileShow(params: { + page: string + worktree?: string + }): Promise { + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + const tab = this.describeBrowserTab(params.page, worktreeId) + return { + browserPageId: tab.browserPageId, + worktreeId: tab.worktreeId ?? null, + profileId: tab.profileId ?? null, + profileLabel: tab.profileLabel ?? null + } + } + + async browserTabProfileClone( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const sourceBrowserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!sourceBrowserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + const created = await this.createBrowserTabInRenderer( + sourceTab.url, + sourceTab.worktreeId ?? target.worktreeId, + profile.id + ) + // Why: parity with browserTabCreate. Wait for the cloned tab's webview to + // register so the returned browserPageId is operable by the next CLI call. + try { + await waitForTabRegistration(created.browserPageId) + } catch { + // Best-effort: registration may not fire if the worktree is hidden. + } + return { + browserPageId: created.browserPageId, + sourceBrowserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserProfileList(): Promise { + return { profiles: browserSessionRegistry.listProfiles() } + } + + async browserProfileCreate(params: { + label: string + scope: 'isolated' | 'imported' + }): Promise { + return { + profile: browserSessionRegistry.createProfile(params.scope, params.label) + } + } + + async browserProfileDelete(params: { profileId: string }): Promise { + return { + deleted: await browserSessionRegistry.deleteProfile(params.profileId), + profileId: params.profileId + } + } + async browserTabClose(params: { index?: number page?: string @@ -3112,6 +6609,84 @@ export class OrcaRuntimeService { return { closed: true } } + private enrichBrowserTabInfo( + tab: BrowserTabListResult['tabs'][number] + ): BrowserTabListResult['tabs'][number] { + const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) + const profile = + browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? + browserSessionRegistry.getDefaultProfile() + return { + ...tab, + worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, + profileId: profile.id, + profileLabel: profile.label + } + } + + private describeBrowserTab( + browserPageId: string, + explicitWorktreeId?: string + ): BrowserTabListResult['tabs'][number] { + const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) + const tab = this.requireAgentBrowserBridge() + .tabList(worktreeId) + .tabs.find((entry) => entry.browserPageId === browserPageId) + if (!tab) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + return this.enrichBrowserTabInfo(tab) + } + + private async createBrowserTabInRenderer( + url: string, + worktreeId?: string, + profileId?: string + ): Promise<{ browserPageId: string }> { + const win = this.getAuthoritativeWindow() + const requestId = randomUUID() + + if (worktreeId) { + await this.ensureBrowserWorktreeActive(worktreeId) + } + + const browserPageId = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCreateReply', handler) + reject(new Error('Tab creation timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; browserPageId?: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCreateReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve(reply.browserPageId!) + } + } + ipcMain.on('browser:tabCreateReply', handler) + win.webContents.send('browser:requestTabCreate', { + requestId, + url, + worktreeId, + sessionProfileId: profileId + }) + }) + + return { browserPageId } + } + private getAuthoritativeWindow(): BrowserWindow { if (this.authoritativeWindowId === null) { throw new Error('No renderer window available') @@ -3128,11 +6703,25 @@ const MAX_TAIL_LINES = 120 const MAX_TAIL_CHARS = 4000 const MAX_PREVIEW_LINES = 6 const MAX_PREVIEW_CHARS = 300 +const WORKTREE_STATUS_PRIORITY: Record = { + inactive: 0, + active: 1, + done: 2, + working: 3, + permission: 4 +} const DEFAULT_REPO_SEARCH_REFS_LIMIT = 25 const DEFAULT_TERMINAL_LIST_LIMIT = 200 const DEFAULT_WORKTREE_LIST_LIMIT = 200 const DEFAULT_WORKTREE_PS_LIMIT = 200 const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 +// Why (§3.3): 30s freshness window. A second worktree-create or dispatch-probe +// against the same repo+remote within this window reuses the previous successful +// fetch instead of repeating the round-trip. Chosen so rapid "new worktree" +// clicks and successive coordinator dispatches feel snappy, while still being +// short enough that a genuinely-changed remote is observed on the next action. +const FETCH_FRESHNESS_MS = 30_000 +const DRIFT_PROBE_SUBJECT_LIMIT = 5 function buildPreview(lines: string[], partialLine: string): string { const previewLines = buildTailLines(lines, partialLine) .map((line) => line.trim()) @@ -3230,6 +6819,14 @@ const TUI_IDLE_POLL_INTERVAL_MS = 2000 const TUI_IDLE_QUIESCENCE_MS = 3000 const MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000 +// Clamp range for the user-facing mobileAutoRestoreFitMs preference. +// MIN floor: a couple of seconds is the smallest useful auto-restore +// (anything tighter is the legacy 300ms debounce). +// MAX ceiling: one hour — a held PTY beyond that is almost certainly +// "I forgot" rather than intentional. +const MOBILE_AUTO_RESTORE_FIT_MIN_MS = 5_000 +const MOBILE_AUTO_RESTORE_FIT_MAX_MS = 60 * 60 * 1000 + function buildTerminalWaitResult( handle: string, condition: RuntimeTerminalWaitCondition, @@ -3244,6 +6841,20 @@ function buildTerminalWaitResult( } } +function buildPtyTerminalWaitResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + pty: RuntimePtyWorktreeRecord +): RuntimeTerminalWait { + return { + handle, + condition, + satisfied: true, + status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', + exitCode: pty.lastExitCode + } +} + function branchSelectorMatches(branch: string, selector: string): boolean { // Why: Git worktree data can report local branches as either `refs/heads/foo` // or `foo` depending on which plumbing path produced the record. Orca's @@ -3256,6 +6867,96 @@ function normalizeBranchRef(branch: string): string { return branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch } +function inferWorktreeIdFromPtyId(ptyId: string): string | null { + const separatorIndex = ptyId.lastIndexOf('@@') + if (separatorIndex <= 0) { + return null + } + const worktreeId = ptyId.slice(0, separatorIndex) + return parseRuntimeWorktreeId(worktreeId) ? worktreeId : null +} + +function parseRuntimeWorktreeId( + worktreeId: string +): { repoId: string; worktreePath: string } | null { + const separatorIndex = worktreeId.indexOf('::') + if (separatorIndex <= 0) { + return null + } + const worktreePath = worktreeId.slice(separatorIndex + 2) + if (!worktreePath) { + return null + } + return { + repoId: worktreeId.slice(0, separatorIndex), + worktreePath + } +} + +function findResolvedWorktreeIdForPath( + resolvedWorktrees: ResolvedWorktree[], + cwd: string +): string | null { + if (!cwd) { + return null + } + const matches = resolvedWorktrees + .filter( + (worktree) => + areWorktreePathsEqual(worktree.path, cwd) || isPathInsideWorktree(cwd, worktree.path) + ) + .sort((left, right) => right.path.length - left.path.length) + return matches[0]?.id ?? null +} + +function isPathInsideWorktree(candidatePath: string, worktreePath: string): boolean { + if (candidatePath === worktreePath) { + return true + } + const normalizedCandidate = candidatePath.replace(/\\/g, '/').replace(/\/+$/, '') + const normalizedWorktree = worktreePath.replace(/\\/g, '/').replace(/\/+$/, '') + return normalizedCandidate.startsWith(`${normalizedWorktree}/`) +} + +function getLeafWorktreeStatus( + leaf: RuntimeLeafRecord, + tabTitle: string | null +): RuntimeWorktreeStatus { + // Why: recompute from the live title each call so worktree.ps mirrors what + // the desktop sidebar's getWorktreeStatus does (no sticky state). Prefer + // the runtime-tracked OSC title (covers daemon-hosted terminals) over the + // renderer-pushed leaf.title and the tab title. Falling back to + // lastAgentStatus only when no title is available preserves a sensible + // signal for very fresh leaves before any title has been observed. + const liveTitle = leaf.lastOscTitle ?? leaf.title ?? tabTitle ?? '' + const detected = liveTitle ? detectAgentStatusFromTitle(liveTitle) : leaf.lastAgentStatus + if (detected === 'permission') { + return 'permission' + } + if (detected === 'working') { + return 'working' + } + return leaf.ptyId ? 'active' : 'inactive' +} + +function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus { + const detected = detectAgentStatusFromTitle(title) + if (detected === 'permission') { + return 'permission' + } + if (detected === 'working') { + return 'working' + } + return hasPty ? 'active' : 'inactive' +} + +function mergeWorktreeStatus( + current: RuntimeWorktreeStatus, + next: RuntimeWorktreeStatus +): RuntimeWorktreeStatus { + return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current +} + function normalizeTerminalChunk(chunk: string): string { return chunk .replace(/\r\n/g, '\n') @@ -3281,6 +6982,13 @@ function compareWorktreePs( left: RuntimeWorktreePsSummary, right: RuntimeWorktreePsSummary ): number { + // Pinned and unread worktrees sort above others so they survive truncation. + if (left.isPinned !== right.isPinned) { + return left.isPinned ? -1 : 1 + } + if (left.unread !== right.unread) { + return left.unread ? -1 : 1 + } const leftLast = left.lastOutputAt ?? -1 const rightLast = right.lastOutputAt ?? -1 if (leftLast !== rightLast) { diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap new file mode 100644 index 00000000000..8c8fd9f09f5 --- /dev/null +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -0,0 +1,83 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`buildDispatchPreamble > renders a stable snapshot of the full preamble 1`] = ` +"You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your coordinator's terminal handle is: term_COORD +Your task ID is: task_SNAP + +You talk to the coordinator only through the CLI commands below. Do not use +Slack, GitHub comments, or any other channel to reach a human during the run. + +=== CLI COMMANDS === + + # Report task completion (REQUIRED when done — even on failure). + # + # RULE: --body must be a 3-sentence executive summary (what you did, + # what you found, what's left). Never send an empty body; the coordinator + # reads the body first and only opens artifacts if it needs more detail. + # If you produced a long-form artifact, include its path as + # payload.reportPath so the coordinator can find it without a file search. + # + # RULE: send worker_done exactly once. Failure is still a worker_done + # with subject like "Failed: " — never silently exit. + orca orchestration send --to term_COORD \\ + --type worker_done --subject "" \\ + --body "<3-sentence summary: what you did, what you found, what's left>" \\ + --payload '{"taskId":"task_SNAP","filesModified":["path/a","path/b"],"reportPath":""}' + + # BEHAVIOR RULE: send a heartbeat every 5 minutes + # while actively working on the task. The coordinator uses this to + # distinguish "still thinking" from "hung / crashed." Skip heartbeats only + # while blocked inside \`check --wait\` or \`ask\` — those calls are + # themselves liveness signals. + # + # Include BOTH taskId and dispatchId in the payload: the coordinator + # attributes the heartbeat to the specific dispatch context, not just + # the task, so a straggler heartbeat from a previously-failed dispatch + # cannot mask a hung retry. + orca orchestration send --to term_COORD \\ + --type heartbeat --subject "alive" \\ + --payload '{"taskId":"task_SNAP","dispatchId":"ctx_SNAP","phase":""}' + + # Ask the coordinator a question and block until it answers. + # + # BEHAVIOR RULE #1 (MUST NOT VIOLATE): + # NEVER use AskUserQuestion; use \`orca orchestration ask\` or send + # --type decision_gate. AskUserQuestion opens a local TUI prompt that the + # coordinator cannot see and cannot answer — your session will hang forever + # waiting on a human. Every interactive question goes through \`ask\` below. + # + # The \`ask\` verb is a thin wrapper: it sends a decision_gate message and + # blocks on \`check --wait\` until the coordinator replies, then prints the + # reply body. Use it anywhere you would otherwise have reached for + # AskUserQuestion. + orca orchestration ask --to term_COORD \\ + --question "" \\ + --options "" \\ + --timeout-ms 600000 + + # Escalate a blocker or failure (pre-completion, when you need the + # coordinator to do something before you can continue): + orca orchestration send --to term_COORD \\ + --type escalation --subject "Blocked: " \\ + --body "
" \\ + --payload '{"taskId":"task_SNAP"}' + + # Check for messages from the coordinator: + orca orchestration check + +=== AFTER YOU SEND worker_done === + +Keep the shell session open for a grace period (10 minutes) in case the +coordinator sends a follow-up or re-dispatches you. Poll with +\`orca orchestration check\` every 2 minutes during that window. If no +follow-up arrives, you may exit after the grace period — the coordinator +will not expect further output from you. + +If the coordinator re-dispatches you (you will receive a fresh preamble + +TASK block), reset your polling and start the new task. Do not respond +to the previous task's follow-ups after a re-dispatch. + +=== TASK === +TASK_BODY" +`; diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index ac0ebbb4974..5c1ff4e22f7 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -1,12 +1,27 @@ /* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */ import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' -import { Coordinator, type CoordinatorRuntime } from './coordinator' +import { + Coordinator, + DISPATCH_STALE_THRESHOLD, + parseAllowStaleBaseFromSpec, + type CoordinatorRuntime +} from './coordinator' + +type DriftResult = { + base: string + behind: number + recentSubjects: string[] +} | null function createMockRuntime(): CoordinatorRuntime & { sentMessages: { handle: string; text: string }[] terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[] createdTerminals: string[] + probeDriftCalls: string[] + probeDriftResult: DriftResult + setProbeDrift(result: DriftResult): void + throwProbeDrift: Error | null } { const mock = { sentMessages: [] as { handle: string; text: string }[], @@ -17,6 +32,12 @@ function createMockRuntime(): CoordinatorRuntime & { writable: boolean }[], createdTerminals: [] as string[], + probeDriftCalls: [] as string[], + probeDriftResult: null as DriftResult, + throwProbeDrift: null as Error | null, + setProbeDrift(result: DriftResult): void { + mock.probeDriftResult = result + }, async sendTerminal(handle: string, action: { text?: string }) { mock.sentMessages.push({ handle, text: action.text ?? '' }) return { handle, accepted: true, bytesWritten: 0 } @@ -32,6 +53,13 @@ function createMockRuntime(): CoordinatorRuntime & { }, async waitForTerminal(handle: string) { return { handle, condition: 'exit' } + }, + async probeWorktreeDrift(worktreeSelector: string): Promise { + mock.probeDriftCalls.push(worktreeSelector) + if (mock.throwProbeDrift) { + throw mock.throwProbeDrift + } + return mock.probeDriftResult } } return mock @@ -355,6 +383,88 @@ describe('Coordinator', () => { expect(result.status).toBe('completed') }) + it('logs a stale warning for dispatched rows past the threshold and does not auto-fail', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + // No terminals available so dispatchReadyTasks creates one and we can + // drive the stale-scan deterministically via SQL backdating. + const task = db.createTask({ spec: 'work' }) + const ctx = db.createDispatchContext(task.id, 'term_stale') + + // Backdate dispatched_at and last_heartbeat_at beyond the 10-min threshold + // so getStaleDispatches returns this row on the first tick. + const sqlite = ( + db as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => void } } } + ).db + const iso = (ms: number) => new Date(Date.now() - ms).toISOString() + sqlite + .prepare('UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?') + .run(iso(60 * 60 * 1000), iso(30 * 60 * 1000), ctx.id) + + const logs: string[] = [] + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20, + onLog: (m) => logs.push(m) + }) + + // Drive one tick then stop — we only need the stale warning to have fired. + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 80) + }) + coordinator.stop() + await runPromise + + expect(logs.some((l) => /has not sent a heartbeat/.test(l) && l.includes(task.id))).toBe(true) + // Task status must NOT have been auto-failed — logging only. + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('records heartbeat by dispatchId on worker heartbeat messages', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + + const task = db.createTask({ spec: 'work' }) + const ctx = db.createDispatchContext(task.id, 'term_a') + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 20 + }) + + const runPromise = coordinator.run() + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id, phase: 'implementing' }) + }) + + await new Promise((r) => { + setTimeout(r, 80) + }) + + expect(db.getDispatchContext(task.id)?.last_heartbeat_at).toBeTruthy() + + // Complete the task so the coordinator run finishes cleanly. + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + }) + it('can be stopped', async () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() @@ -376,4 +486,287 @@ describe('Coordinator', () => { const result = await runPromise expect(result.status).toBe('failed') }) + + describe('stale-base dispatch guard', () => { + it('threads drift into the preamble when behind > 0 and under threshold', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.setProbeDrift({ + base: 'origin/main', + behind: 5, + recentSubjects: ['fix A', 'fix B', 'fix C'] + }) + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(runtime.probeDriftCalls).toContain('wt1') + const sent = runtime.sentMessages.find((m) => m.handle === 'term_a') + expect(sent).toBeDefined() + expect(sent!.text).toContain('--- BASE DRIFT ---') + expect(sent!.text).toContain('5 commits behind origin/main') + expect(sent!.text).toContain('fix A') + }) + + it('silently skips dispatch when drift > threshold and allow-stale-base is absent', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.setProbeDrift({ + base: 'origin/main', + behind: DISPATCH_STALE_THRESHOLD + 10, + recentSubjects: ['fix A'] + }) + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 250) + }) + coordinator.stop() + const result = await runPromise + + // Why: silent-skip must NOT burn the circuit-breaker budget. Task must + // stay in `ready`; failDispatch must NOT be called; sendTerminal must + // NOT be called; no dispatch context should exist. + expect(runtime.sentMessages).toHaveLength(0) + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeUndefined() + // Coordinator was stopped externally, so overall status is 'failed' + // because tasks are not complete — but the task itself never dispatched. + expect(result.status).toBe('failed') + expect(result.failedTasks).not.toContain(task.id) + }) + + it('proceeds with stripped spec + drift section when allow-stale-base overrides', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.setProbeDrift({ + base: 'origin/main', + behind: 200, + recentSubjects: ['commit 1', 'commit 2'] + }) + + const spec = `Investigate issue #42 +allow-stale-base: true` + const task = db.createTask({ spec }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + const sent = runtime.sentMessages.find((m) => m.handle === 'term_a') + expect(sent).toBeDefined() + expect(sent!.text).toContain('--- BASE DRIFT ---') + expect(sent!.text).toContain('200 commits behind origin/main') + // Why (§3.4): stripped spec must not contain the infra flag line. + expect(sent!.text).toContain('Investigate issue #42') + expect(sent!.text).not.toContain('allow-stale-base: true') + }) + + it('proceeds without drift section when probeWorktreeDrift returns null', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.setProbeDrift(null) + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + const sent = runtime.sentMessages.find((m) => m.handle === 'term_a') + expect(sent).toBeDefined() + expect(sent!.text).not.toContain('--- BASE DRIFT ---') + }) + + it('does not call probeWorktreeDrift when coordinator has no worktree selector', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + const logs: string[] = [] + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + // worktree deliberately omitted + onLog: (msg) => logs.push(msg) + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(runtime.probeDriftCalls).toHaveLength(0) + expect(logs.some((m) => m.includes('stale-base guard inert'))).toBe(true) + // Dispatch still went through normally. + expect(runtime.sentMessages.length).toBeGreaterThan(0) + }) + + it('proceeds without drift when probeWorktreeDrift throws', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.throwProbeDrift = new Error('boom') + + const task = db.createTask({ spec: 'do the work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + worktree: 'wt1' + }) + + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + const sent = runtime.sentMessages.find((m) => m.handle === 'term_a') + expect(sent!.text).not.toContain('--- BASE DRIFT ---') + }) + }) +}) + +describe('parseAllowStaleBaseFromSpec', () => { + it('matches canonical form on its own line and strips it', () => { + const spec = `Do the work +allow-stale-base: true` + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(true) + expect(strippedSpec).toBe('Do the work\n') + expect(strippedSpec).not.toContain('allow-stale-base') + }) + + it('matches case-insensitively', () => { + const spec = `Do the work +Allow-Stale-Base: TRUE` + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(true) + expect(strippedSpec).not.toMatch(/[Aa]llow-[Ss]tale-[Bb]ase/) + }) + + it('does not match allow-stale-base: false', () => { + const spec = `Do the work +allow-stale-base: false` + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(false) + expect(strippedSpec).toBe(spec) + }) + + it('does not match allow-stale-base: truthy', () => { + const spec = `Do the work +allow-stale-base: truthy` + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(false) + expect(strippedSpec).toBe(spec) + }) + + it('does not match the flag embedded inside a sentence', () => { + const spec = 'we allow-stale-base: true sometimes' + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(false) + expect(strippedSpec).toBe(spec) + }) + + it('handles the flag as the last line with no trailing newline', () => { + const spec = 'line 1\nallow-stale-base: true' + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(spec) + expect(allowStale).toBe(true) + expect(strippedSpec).toBe('line 1\n') + expect(strippedSpec.endsWith('allow-stale-base: true')).toBe(false) + }) }) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 541102f4ae5..2dc02867887 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -19,6 +19,45 @@ export type CoordinatorRuntime = { handle: string, options?: { condition?: string; timeoutMs?: number } ): Promise<{ handle: string; condition: string }> + // Why (§3.1): dispatch pre-flight drift check lives on the runtime because + // it needs to resolve a worktree selector, load the repo, and fetch. The + // coordinator only knows about handles + specs; resolving a git worktree + // from this layer would leak transport details here. + probeWorktreeDrift(worktreeSelector: string): Promise<{ + base: string + behind: number + recentSubjects: string[] + } | null> +} + +// Why (§3.1): single threshold, no warn/refuse split. Coordinator picked 20 +// in msg_eff3a646110d — lets normal day-of-velocity on active monorepos pass +// while still tripping on the 168-commit harm observed in ORCHESTRATOR_FEEDBACK.md. +export const DISPATCH_STALE_THRESHOLD = 20 + +// Why (§3.4): the flag is stashed in the task spec text rather than a DB +// column in v1. The regex is intentionally narrow — only the canonical form +// matches, so typos fail closed (dispatch refuses). Returning the stripped +// spec alongside the boolean keeps this infra line out of the worker's +// `--- TASK ---` block (workers would otherwise read it as an instruction). +// +// Trade-off (§7.9): the regex matches any line of the spec including lines +// inside fenced code blocks. Acceptable v1 limitation — the failure mode is +// "dispatches through when the author didn't intend to," which the preamble +// drift section surfaces to the worker. Skill doc directs authors to place +// the flag as the last line and avoid the literal flag in code examples. +const ALLOW_STALE_BASE_RE = /^[ \t]*allow-stale-base:[ \t]*true[ \t]*\r?$/im +const ALLOW_STALE_BASE_STRIP_RE = /^[ \t]*allow-stale-base:[ \t]*true[ \t]*\r?\n?/im + +export function parseAllowStaleBaseFromSpec(spec: string): { + allowStale: boolean + strippedSpec: string +} { + if (!ALLOW_STALE_BASE_RE.test(spec)) { + return { allowStale: false, strippedSpec: spec } + } + const strippedSpec = spec.replace(ALLOW_STALE_BASE_STRIP_RE, '') + return { allowStale: true, strippedSpec } } export type CoordinatorOptions = { @@ -41,6 +80,13 @@ type CoordinatorState = { const DEFAULT_POLL_MS = 2000 const MAX_CONCURRENT_DEFAULT = 4 +// Why: 10 min matches the preamble's documented heartbeat cadence (5 min) × +// 2, so a single missed heartbeat is the earliest a dispatch can look stale. +// Keeping this in one place (not a per-call arg) ensures the preamble copy +// and the detector logic stay aligned; moving it to a config would multiply +// the places this constant must be kept in sync. +const HUNG_THRESHOLD_MS = 10 * 60 * 1000 + export class Coordinator { private db: OrchestrationDb private runtime: CoordinatorRuntime @@ -173,10 +219,27 @@ export class Coordinator { this.processMessages() this.processEscalations() this.processDecisionGates() + this.warnStaleDispatches() await this.dispatchReadyTasks() return this.checkConvergence() } + // Why: emit a single warning per stale dispatch per tick. This intentionally + // does NOT auto-fail the dispatch — the false-positive cost (a slow worker + // producing correct output) is higher than the false-negative cost (a hung + // worker keeps its terminal slot until a human notices). Auto-fail policy + // is a separate decision documented in R6 of DESIGN_DOC_PREAMBLE_FIX.md. + private warnStaleDispatches(): void { + const thresholdIso = new Date(Date.now() - HUNG_THRESHOLD_MS).toISOString() + const stale = this.db.getStaleDispatches(thresholdIso) + for (const ctx of stale) { + const minutes = Math.round(HUNG_THRESHOLD_MS / 60000) + this.opts.onLog( + `Warning: worker ${ctx.assignee_handle ?? ''} on task ${ctx.task_id} has not sent a heartbeat in ~${minutes} min (dispatch ${ctx.id})` + ) + } + } + private processMessages(): void { const messages = this.db.getUnreadMessages(this.opts.coordinatorHandle) if (messages.length === 0) { @@ -194,6 +257,9 @@ export class Coordinator { case 'decision_gate': this.handleDecisionGateMessage(msg) break + case 'heartbeat': + this.handleHeartbeat(msg) + break case 'status': this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`) break @@ -205,6 +271,35 @@ export class Coordinator { this.db.markAsRead(messages.map((m) => m.id)) } + // Why: attribute heartbeats to the specific dispatchId, not a + // (taskId, from_handle) lookup. A task that gets retried after a failed + // dispatch has multiple rows in dispatch_contexts — a late heartbeat from + // the previous (failed) assignee arriving while the new dispatch is active + // would falsely bump the new row's last_heartbeat_at if we resolved by + // "latest dispatch for this task" (§5.3.4). If the worker drops dispatchId + // from the payload, log-and-skip is the preferred failure mode: the stale + // detector will correctly flag the dispatch as hung because nothing + // refreshed last_heartbeat_at. + private handleHeartbeat(msg: MessageRow): void { + if (!msg.payload) { + this.opts.onLog(`Heartbeat from ${msg.from_handle} missing payload; ignored`) + return + } + let payload: { dispatchId?: unknown } = {} + try { + payload = JSON.parse(msg.payload) + } catch { + this.opts.onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`) + return + } + const dispatchId = payload.dispatchId + if (typeof dispatchId !== 'string' || dispatchId.length === 0) { + this.opts.onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`) + return + } + this.db.recordHeartbeat(dispatchId, msg.created_at) + } + private handleWorkerDone(msg: MessageRow): void { this.opts.onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`) @@ -383,15 +478,69 @@ export class Coordinator { } private async dispatchTask(task: TaskRow, targetHandle: string): Promise { + // Why (§3.1): pre-flight drift check BEFORE `createDispatchContext` so a + // refusal does NOT increment failure_count. createDispatchContext carries + // `MAX(failure_count)` forward across contexts (db.ts:301-306), so burning + // the circuit-breaker budget here would convert a recoverable "fetch and + // retry" into a hard `failed` task within ~6s of polling. Silent return + // leaves the task in `ready`; the next `dispatchReadyTasks` tick retries + // naturally, and once the coordinator's worktree has been refreshed + // dispatch proceeds cleanly. + const { allowStale, strippedSpec } = parseAllowStaleBaseFromSpec(task.spec) + let baseDrift: { + base: string + behind: number + recentSubjects: string[] + } | null = null + + if (!this.opts.worktree) { + // Why (§7.4): CoordinatorOptions.worktree is optional. When undefined, + // probeWorktreeDrift cannot resolve a selector; log once so operators + // can see the guard did not run for this task and proceed. v2 may + // always resolve a worktree via the coordinator-terminal handle. + this.opts.onLog(`stale-base guard inert for ${task.id}: coordinator has no worktree selector`) + } else { + baseDrift = await this.runtime.probeWorktreeDrift(this.opts.worktree).catch((err) => { + this.opts.onLog(`probeWorktreeDrift failed for ${this.opts.worktree}: ${err}`) + return null + }) + + if (baseDrift && baseDrift.behind > DISPATCH_STALE_THRESHOLD && !allowStale) { + // Why (§3.1): silent-return, NOT failDispatch (which would burn the + // circuit-breaker budget). The message lists three remediations so + // the operator can recover via any of them. + this.opts.onLog( + `Skipping dispatch of ${task.id}: worktree is ${baseDrift.behind} commits ` + + `behind ${baseDrift.base}. Pull/rebase the worktree, recreate it with ` + + `--base-branch ${baseDrift.base}, or include 'allow-stale-base: true' ` + + `in the task spec to override. Task remains in 'ready'; coordinator ` + + `will retry on the next tick.` + ) + return + } + } + const dispatch = this.db.createDispatchContext(task.id, targetHandle) // Why: agents dispatched by the coordinator must use orca-dev in dev mode // so they talk to the dev runtime's socket, not production (Section 6.4). + // Why (§3.4): `strippedSpec` drops the `allow-stale-base: true` line so + // the worker's `--- TASK ---` block does not contain the infra flag (which + // the worker would otherwise read as part of its instructions). const preamble = buildDispatchPreamble({ taskId: task.id, - taskSpec: task.spec, + dispatchId: dispatch.id, + // Why (§3.4, stale-base PR): use `strippedSpec` not `task.spec` so the + // `allow-stale-base: true` line isn't rendered into the worker's + // --- TASK --- block (worker would otherwise treat the infra flag as + // part of its instructions). + taskSpec: strippedSpec, coordinatorHandle: this.opts.coordinatorHandle, - devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') + devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev'), + // Why (§3.2): drift section fires only when behind > 0. The preamble + // builder gates on this itself; passing the object unconditionally lets + // the coordinator stay dumb about the display rule. + ...(baseDrift ? { baseDrift } : {}) }) // Why: check if the task was previously blocked by a decision gate that diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index d412255fdae..9e9d5d941b3 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -1,4 +1,8 @@ /* eslint-disable max-lines -- Why: DB tests cover messages, tasks, dispatch contexts, decision gates, coordinator runs, and lifecycle in one suite to share the createDb() helper and afterEach cleanup. */ +import Database from 'better-sqlite3' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' import type { MessageType } from './db' @@ -198,6 +202,36 @@ describe('OrchestrationDb', () => { expect(d.listTasks()).toHaveLength(2) }) + it('listTasksWithDispatch joins active dispatch metadata', () => { + const d = createDb() + const ready = d.createTask({ spec: 'ready task' }) + const dispatched = d.createTask({ spec: 'active task' }) + const ctx = d.createDispatchContext(dispatched.id, 'term_worker') + + const rows = d.listTasksWithDispatch() + const readyRow = rows.find((r) => r.id === ready.id) + const dispatchedRow = rows.find((r) => r.id === dispatched.id) + + expect(readyRow?.assignee_handle).toBeNull() + expect(readyRow?.dispatch_id).toBeNull() + expect(dispatchedRow?.assignee_handle).toBe('term_worker') + expect(dispatchedRow?.dispatch_id).toBe(ctx.id) + }) + + it('listTasksWithDispatch does not surface completed dispatches', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + d.createDispatchContext(task.id, 'term_worker') + d.updateTaskStatus(task.id, 'completed') + + const rows = d.listTasksWithDispatch() + const row = rows.find((r) => r.id === task.id) + // Task is completed — its dispatch is terminal and should not appear as + // an "active" assignee. + expect(row?.assignee_handle).toBeNull() + expect(row?.dispatch_id).toBeNull() + }) + it('supports parent_id for task decomposition', () => { const d = createDb() const parent = d.createTask({ spec: 'parent' }) @@ -454,4 +488,278 @@ describe('OrchestrationDb', () => { expect(d.listTasks()).toHaveLength(0) }) }) + + describe('heartbeat + thread helpers (fresh schema)', () => { + it('insertMessage accepts type = heartbeat', () => { + const d = createDb() + const msg = d.insertMessage({ + from: 'worker', + to: 'coord', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ taskId: 'task_x', dispatchId: 'ctx_x' }) + }) + expect(msg.type).toBe('heartbeat') + }) + + it('recordHeartbeat updates last_heartbeat_at on dispatched rows', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + + d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') + const after = d.getDispatchContext(task.id) + expect(after?.last_heartbeat_at).toBe('2026-05-04T00:00:00.000Z') + }) + + it('recordHeartbeat is a no-op for completed rows (straggler ignored)', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.completeDispatch(ctx.id) + + d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') + const after = d.getDispatchContext(task.id) + expect(after?.last_heartbeat_at).toBeNull() + }) + + it('getStaleDispatches returns only dispatched rows past the grace window', () => { + const d = createDb() + // Fixture: four rows, SQL-backdated timestamps (no fake clock): + // (a) dispatched, heartbeated 5 min ago → not stale + // (b) dispatched, heartbeated 12 min ago → STALE (expected result) + // (c) dispatched, never heartbeated, dispatched 30s ago → not stale (grace) + // (d) completed, heartbeated 30 min ago → not stale (status filter) + const taskA = d.createTask({ spec: 'a' }) + const taskB = d.createTask({ spec: 'b' }) + const taskC = d.createTask({ spec: 'c' }) + const taskD = d.createTask({ spec: 'd' }) + const ctxA = d.createDispatchContext(taskA.id, 'term_a') + const ctxB = d.createDispatchContext(taskB.id, 'term_b') + const ctxC = d.createDispatchContext(taskC.id, 'term_c') + const ctxD = d.createDispatchContext(taskD.id, 'term_d') + d.completeDispatch(ctxD.id) + + const now = Date.now() + const iso = (ms: number) => new Date(now - ms).toISOString() + + // Backdate dispatched_at for a, b, d to long ago so the grace doesn't + // shield them. c keeps its default (≈now). + const sqlite = (d as unknown as { db: Database.Database }).db + sqlite + .prepare( + 'UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?' + ) + .run(iso(60 * 60 * 1000), iso(5 * 60 * 1000), ctxA.id) + sqlite + .prepare( + 'UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?' + ) + .run(iso(60 * 60 * 1000), iso(12 * 60 * 1000), ctxB.id) + sqlite + .prepare('UPDATE dispatch_contexts SET dispatched_at = ? WHERE id = ?') + .run(iso(30_000), ctxC.id) + sqlite + .prepare( + 'UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?' + ) + .run(iso(60 * 60 * 1000), iso(30 * 60 * 1000), ctxD.id) + + const stale = d.getStaleDispatches(iso(10 * 60 * 1000)) + expect(stale.map((s) => s.id)).toEqual([ctxB.id]) + }) + + it('getThreadMessagesFor returns only same-thread replies to a handle', () => { + const d = createDb() + const outbound = d.insertMessage({ + from: 'worker', + to: 'coord', + subject: 'Question', + type: 'decision_gate', + body: 'yes or no?' + }) + // Reply in the same thread addressed to the worker + const reply = d.insertMessage({ + from: 'coord', + to: 'worker', + subject: 'Re: Question', + body: 'yes', + threadId: outbound.id + }) + // Distractor: different thread, same recipient + d.insertMessage({ + from: 'coord', + to: 'worker', + subject: 'other', + body: 'unrelated', + threadId: 'thread_other' + }) + // Distractor: same thread but not addressed to worker + d.insertMessage({ + from: 'coord', + to: 'someone_else', + subject: 'cc', + body: 'not yours', + threadId: outbound.id + }) + + const replies = d.getThreadMessagesFor(outbound.id, 'worker', outbound.sequence) + expect(replies).toHaveLength(1) + expect(replies[0].id).toBe(reply.id) + }) + }) + + describe('schema migration from v1 → v2', () => { + let dbPath: string + let tempDir: string + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + function createV1Snapshot(): string { + tempDir = mkdtempSync(join(tmpdir(), 'orca-db-migrate-')) + dbPath = join(tempDir, 'test.db') + const raw = new Database(dbPath) + // v1 schema: pre-heartbeat CHECK, no last_heartbeat_at column. + raw.exec(` + CREATE TABLE messages ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE UNIQUE INDEX idx_messages_id ON messages(id); + CREATE INDEX idx_inbox ON messages(to_handle, read); + CREATE INDEX idx_thread ON messages(thread_id); + + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, parent_id TEXT, spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','ready','dispatched','completed','failed','blocked')), + deps TEXT NOT NULL DEFAULT '[]', result TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + + CREATE TABLE dispatch_contexts ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, assignee_handle TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','dispatched','completed','failed','circuit_broken')), + failure_count INTEGER NOT NULL DEFAULT 0, last_failure TEXT, + dispatched_at TEXT, completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE decision_gates ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, question TEXT NOT NULL, + options TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','resolved','timeout')), + resolution TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT + ); + + CREATE TABLE coordinator_runs ( + id TEXT PRIMARY KEY, spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle' + CHECK(status IN ('idle','running','completed','failed')), + coordinator_handle TEXT NOT NULL, + poll_interval_ms INTEGER NOT NULL DEFAULT 2000, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + `) + // Seed a pre-existing v1 message so migration must preserve data. + raw + .prepare( + `INSERT INTO messages (id, from_handle, to_handle, subject, type) VALUES ('msg_v1', 'a', 'b', 'pre-migration', 'status')` + ) + .run() + raw.pragma('user_version = 0') + raw.close() + return dbPath + } + + it('migrates a v1 snapshot to v2, accepts heartbeat, preserves indexes', () => { + const path = createV1Snapshot() + const d = new OrchestrationDb(path) + db = d + + // (a) INSERT type='heartbeat' now succeeds + expect(() => + d.insertMessage({ + from: 'w', + to: 'c', + subject: 'alive', + type: 'heartbeat', + payload: '{"taskId":"t","dispatchId":"ctx"}' + }) + ).not.toThrow() + + // (b) last_heartbeat_at column exists on dispatch_contexts + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.recordHeartbeat(ctx.id, '2026-05-04T00:00:00.000Z') + expect(d.getDispatchContext(task.id)?.last_heartbeat_at).toBe('2026-05-04T00:00:00.000Z') + + // (c) Indexes still attached to messages post-rebuild. + const sqlite = (d as unknown as { db: Database.Database }).db + const indexes = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'messages' AND name NOT LIKE 'sqlite_%'` + ) + .all() as { name: string }[] + const names = new Set(indexes.map((r) => r.name)) + expect(names.has('idx_messages_id')).toBe(true) + expect(names.has('idx_inbox')).toBe(true) + expect(names.has('idx_thread')).toBe(true) + + // v1 data preserved + expect(d.getMessageById('msg_v1')?.subject).toBe('pre-migration') + }) + + it('is idempotent: opening an already-migrated DB is a no-op', () => { + const path = createV1Snapshot() + const first = new OrchestrationDb(path) + first.insertMessage({ + from: 'w', + to: 'c', + subject: 'alive', + type: 'heartbeat', + payload: '{}' + }) + first.close() + + const second = new OrchestrationDb(path) + db = second + expect(() => + second.insertMessage({ + from: 'w', + to: 'c', + subject: 'again', + type: 'heartbeat', + payload: '{}' + }) + ).not.toThrow() + const inbox = second.getInbox(10) + expect(inbox.length).toBeGreaterThanOrEqual(2) + }) + }) }) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index b7c74bee72e..e8daa9f1708 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -33,6 +33,14 @@ function generateId(prefix: string): string { return `${prefix}_${randomBytes(6).toString('hex')}` } +// Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` +// column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so +// push-on-idle can distinguish queued-but-undelivered from user-acknowledged +// messages without touching the `read` bit (check-wait PR). Bumping together +// since both PRs ship in the same release — existing on-disk DBs must clear +// both gaps in one atomic migration. +const SCHEMA_VERSION = 3 + export class OrchestrationDb { private db: Database.Database @@ -42,6 +50,7 @@ export class OrchestrationDb { this.db.pragma('synchronous = NORMAL') this.db.pragma('busy_timeout = 5000') this.createTables() + this.migrate() } private createTables(): void { @@ -55,7 +64,7 @@ export class OrchestrationDb { type TEXT NOT NULL DEFAULT 'status' CHECK(type IN ( 'status', 'dispatch', 'worker_done', 'merge_ready', - 'escalation', 'handoff', 'decision_gate' + 'escalation', 'handoff', 'decision_gate', 'heartbeat' )), priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('normal', 'high', 'urgent')), @@ -63,7 +72,8 @@ export class OrchestrationDb { payload TEXT, read INTEGER NOT NULL DEFAULT 0, sequence INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + delivered_at TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_id ON messages(id); @@ -89,16 +99,17 @@ export class OrchestrationDb { CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); CREATE TABLE IF NOT EXISTS dispatch_contexts ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - assignee_handle TEXT, - status TEXT NOT NULL DEFAULT 'pending' + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + assignee_handle TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), - failure_count INTEGER NOT NULL DEFAULT 0, - last_failure TEXT, - dispatched_at TEXT, - completed_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + failure_count INTEGER NOT NULL DEFAULT 0, + last_failure TEXT, + dispatched_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_heartbeat_at TEXT ); CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id); @@ -132,6 +143,115 @@ export class OrchestrationDb { `) } + // Why: `CREATE TABLE IF NOT EXISTS` is a no-op against an existing on-disk + // DB, so new schema shapes (added columns, widened CHECK constraints) do + // not reach an upgraded user unless we migrate explicitly. The transaction + // guarantees atomicity — a mid-migration crash leaves the DB at the prior + // version because `user_version` is bumped only on success. Idempotent + // re-invocation is a no-op (current >= SCHEMA_VERSION short-circuit). + private migrate(): void { + const current = this.db.pragma('user_version', { simple: true }) as number + if (current >= SCHEMA_VERSION) { + return + } + + this.db.exec('BEGIN') + try { + // v1 → v2: add last_heartbeat_at column; widen messages.type CHECK to + // include 'heartbeat'. SQLite cannot ALTER a CHECK constraint, so we + // rebuild the messages table. We also include `delivered_at` in the + // rebuilt schema so DBs migrating from v1 pick up the v3 column in a + // single table-rewrite pass (avoids a second messages-rebuild later). + if (current < 2) { + if (!this.hasColumn('dispatch_contexts', 'last_heartbeat_at')) { + this.db.exec(`ALTER TABLE dispatch_contexts ADD COLUMN last_heartbeat_at TEXT`) + } + + if (!this.messagesTypeCheckAllowsHeartbeat()) { + // Why — index list is not optional. createTables() already attached + // idx_messages_id / idx_inbox / idx_thread to the old messages table; + // DROP TABLE removes those indexes with it. CREATE INDEX IF NOT + // EXISTS in createTables() only runs on the next process startup, + // so skipping explicit recreation here would leave every + // getUnreadMessages / getMessageById call full-scanning for the + // rest of this process's lifetime — a silent O(N) perf regression. + // The three CREATE INDEX statements below mirror createTables() + // verbatim so the two definitions cannot drift. + this.db.exec(` + CREATE TABLE messages_new ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate', 'heartbeat' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + delivered_at TEXT + ); + INSERT INTO messages_new ( + id, from_handle, to_handle, subject, body, type, priority, + thread_id, payload, read, sequence, created_at + ) + SELECT + id, from_handle, to_handle, subject, body, type, priority, + thread_id, payload, read, sequence, created_at + FROM messages; + DROP TABLE messages; + ALTER TABLE messages_new RENAME TO messages; + + CREATE UNIQUE INDEX idx_messages_id ON messages(id); + CREATE INDEX idx_inbox ON messages(to_handle, read); + CREATE INDEX idx_thread ON messages(thread_id); + `) + } + } + + // v2 → v3: add `delivered_at` column to messages. A DB that reached v2 + // via the v1 → v2 rebuild above already has the column (we included + // it in messages_new); this handles DBs that were at v2 before this + // release shipped (preamble PR deployed standalone, then check-wait + // merged). ALTER TABLE is idempotent via the hasColumn probe — a + // duplicate-column error would abort the whole transaction. + if (current < 3) { + if (!this.hasColumn('messages', 'delivered_at')) { + this.db.exec(`ALTER TABLE messages ADD COLUMN delivered_at TEXT`) + } + } + + this.db.pragma(`user_version = ${SCHEMA_VERSION}`) + this.db.exec('COMMIT') + } catch (err) { + this.db.exec('ROLLBACK') + throw err + } + } + + private hasColumn(table: string, column: string): boolean { + const rows = this.db.pragma(`table_info(${table})`) as { name: string }[] + return rows.some((r) => r.name === column) + } + + // Why: sqlite_master stores the original CREATE TABLE SQL including the + // CHECK clause. Inspecting that text is the cheapest reliable way to tell + // whether the pre-rebuild schema already knows about 'heartbeat' without + // needing a dedicated schema_meta row. + private messagesTypeCheckAllowsHeartbeat(): boolean { + const row = this.db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages'") + .get() as { sql: string } | undefined + return !!row && row.sql.includes("'heartbeat'") + } + // ── Messages ── insertMessage(msg: { @@ -195,12 +315,56 @@ export class OrchestrationDb { this.db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`).run(...ids) } + // Why: `delivered_at` is stamped via SQLite's datetime('now') rather than a + // JS ISO string so it uses the same 'YYYY-MM-DD HH:MM:SS' UTC shape as the + // other SQL-default timestamps on this table. A future ORDER BY or + // comparison against created_at relies on this format consistency. + // See design doc §3.2. + markAsDelivered(ids: string[]): void { + if (ids.length === 0) { + return + } + const placeholders = ids.map(() => '?').join(',') + this.db + .prepare(`UPDATE messages SET delivered_at = datetime('now') WHERE id IN (${placeholders})`) + .run(...ids) + } + getInbox(limit = 20): MessageRow[] { return this.db .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') .all(limit) as MessageRow[] } + // Why: used by `check --all` and `inbox --terminal ` — returns every + // message for a handle regardless of read/delivered state; never touches the + // read bit. Stale-handle safe: if the handle no longer exists, the query + // just returns whatever historical rows remain (§3.3). + getAllMessagesForHandle(toHandle: string, limit = 100): MessageRow[] { + return this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') + .all(toHandle, limit) as MessageRow[] + } + + // Why: thread-scoped read for the `orchestration.ask` wait loop. Filtered + // by `to_handle` so a worker only sees replies addressed to it (not + // messages it sent), and ordered by `sequence` so the first post-ask + // reply is returned first. `afterSequence` lets the caller resume past an + // already-seen marker without re-reading the outbound ask itself. Uses + // the existing idx_thread index (see createTables) — no new index. + getThreadMessagesFor(threadId: string, toHandle: string, afterSequence?: number): MessageRow[] { + if (afterSequence !== undefined) { + return this.db + .prepare( + 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC' + ) + .all(threadId, toHandle, afterSequence) as MessageRow[] + } + return this.db + .prepare('SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC') + .all(threadId, toHandle) as MessageRow[] + } + // ── Tasks ── createTask(task: { spec: string; deps?: string[]; parentId?: string }): TaskRow { @@ -232,6 +396,50 @@ export class OrchestrationDb { return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[] } + // Why: surfaces the active dispatch (assignee handle + dispatch context id) + // alongside each task so coordinators can answer "who is working on task X?" + // from a single query. The LEFT JOIN keeps non-dispatched tasks in the result + // with NULL assignee/dispatch fields so non-dispatched output stays stable. + // The inner subquery picks the most recent active dispatch per task to match + // the semantics of getDispatchContext for dispatched tasks. + listTasksWithDispatch(filter?: { status?: TaskStatus; ready?: boolean }): (TaskRow & { + assignee_handle: string | null + dispatch_id: string | null + })[] { + const whereClauses: string[] = [] + const params: unknown[] = [] + if (filter?.ready) { + whereClauses.push("t.status = 'ready'") + } else if (filter?.status) { + whereClauses.push('t.status = ?') + params.push(filter.status) + } + const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' + const sql = ` + SELECT + t.*, + d.assignee_handle AS assignee_handle, + d.id AS dispatch_id + FROM tasks t + LEFT JOIN ( + SELECT dc.* + FROM dispatch_contexts dc + INNER JOIN ( + SELECT task_id, MAX(rowid) AS max_rowid + FROM dispatch_contexts + WHERE status IN ('pending', 'dispatched') + GROUP BY task_id + ) latest ON latest.task_id = dc.task_id AND latest.max_rowid = dc.rowid + ) d ON d.task_id = t.id + ${where} + ORDER BY t.created_at + ` + return this.db.prepare(sql).all(...params) as (TaskRow & { + assignee_handle: string | null + dispatch_id: string | null + })[] + } + updateTaskStatus(id: string, status: TaskStatus, result?: string): TaskRow | undefined { const completedAt = status === 'completed' || status === 'failed' ? new Date().toISOString() : null @@ -362,6 +570,40 @@ export class OrchestrationDb { return active ? this.failDispatch(active.id, error) : undefined } + // Why: only touch rows that are currently dispatched. A straggler heartbeat + // from a dispatch that already transitioned to `completed` / `failed` / + // `circuit_broken` MUST NOT retroactively bump `last_heartbeat_at`, because + // the stale-dispatch detector is the signal the coordinator uses to know a + // newer dispatch for the same task has hung. Silently no-op'ing keeps the + // zombie-heartbeat race from masking a hung retry (§5.3.4). + recordHeartbeat(dispatchId: string, at: string): void { + this.db + .prepare( + "UPDATE dispatch_contexts SET last_heartbeat_at = ? WHERE id = ? AND status = 'dispatched'" + ) + .run(at, dispatchId) + } + + // Why: the query restricts to currently-dispatched contexts AND respects a + // dispatched-at grace. Without `status = 'dispatched'`, every completed / + // failed / circuit_broken row with an old-or-null last_heartbeat_at would + // warn every tick (warning storm). Without `dispatched_at < :threshold`, + // a freshly-dispatched worker would trip the warning during its first + // heartbeat interval (false positive). Callers supply the threshold as an + // ISO timestamp so the SQLite string-compare ordering works correctly + // (ISO-8601 compares lexicographically in time order). + getStaleDispatches(thresholdIso: string): DispatchContextRow[] { + return this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE status = 'dispatched' + AND dispatched_at IS NOT NULL + AND dispatched_at < ? + AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)` + ) + .all(thresholdIso, thresholdIso) as DispatchContextRow[] + } + failDispatch(ctxId: string, error: string): DispatchContextRow | undefined { const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as | DispatchContextRow diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index 2b47d40273e..28182e3dbfb 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -16,6 +16,7 @@ function makeMessage(overrides: Partial = {}): MessageRow { read: 0, sequence: 1, created_at: '2026-01-01T00:00:00Z', + delivered_at: null, ...overrides } } diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 71c27d50f66..aeb51e4e9e4 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -1,67 +1,118 @@ +import { spawnSync } from 'child_process' import { describe, expect, it } from 'vitest' import { buildDispatchPreamble } from './preamble' +function baseParams(overrides: Partial[0]> = {}) { + return { + taskId: 'task_abc123', + dispatchId: 'ctx_def456', + taskSpec: 'Implement the login form', + coordinatorHandle: 'term_coord', + ...overrides + } +} + describe('buildDispatchPreamble', () => { it('substitutes template variables', () => { - const result = buildDispatchPreamble({ - taskId: 'task_abc123', - taskSpec: 'Implement the login form', - coordinatorHandle: 'term_coord' - }) + const result = buildDispatchPreamble(baseParams()) expect(result).toContain('task_abc123') + expect(result).toContain('ctx_def456') expect(result).toContain('term_coord') expect(result).toContain('Implement the login form') expect(result).not.toContain('{{') }) - it('includes worker_done command', () => { - const result = buildDispatchPreamble({ - taskId: 'task_x', - taskSpec: 'do stuff', - coordinatorHandle: 'term_c' - }) + it('includes worker_done command with --body 3-sentence summary prompt and reportPath', () => { + const result = buildDispatchPreamble(baseParams()) expect(result).toContain('worker_done') expect(result).toContain('orchestration send') expect(result).toContain('orchestration check') + expect(result).toContain('--body') + expect(result).toMatch(/3-sentence summary/) + expect(result).toContain('reportPath') }) - it('includes the task spec after separator', () => { - const result = buildDispatchPreamble({ - taskId: 'task_x', - taskSpec: 'refactor the auth module', - coordinatorHandle: 'term_c' - }) + it('CLI examples parse as valid shell (bash -n on the extracted block)', () => { + const result = buildDispatchPreamble(baseParams()) + // Why: feeding `bash -n` the full preamble falsely fails on apostrophes + // in the surrounding prose. Slice between the CLI markers and strip + // shell-style comment lines so we only syntax-check the commands. + const cliStart = result.indexOf('=== CLI COMMANDS ===') + const cliEnd = result.indexOf('=== AFTER YOU SEND worker_done ===') + expect(cliStart).toBeGreaterThan(-1) + expect(cliEnd).toBeGreaterThan(cliStart) + const block = result.slice(cliStart, cliEnd) + const stripped = block + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .filter((line) => !line.trim().startsWith('===')) + .join('\n') - expect(result).toContain('--- TASK ---') + const check = spawnSync('bash', ['-n'], { input: stripped, encoding: 'utf8' }) + expect(check.status).toBe(0) + }) + + it('includes heartbeat CLI block with taskId and dispatchId and 5-minute cadence', () => { + const result = buildDispatchPreamble(baseParams()) + expect(result).toContain('--type heartbeat') + expect(result).toContain('--subject "alive"') + expect(result).toMatch(/5 minutes/) + // Both taskId and dispatchId are rendered inside the payload template + // (regression guard for §5.3.4 attribution — dispatchId attribution + // prevents the zombie-heartbeat-masks-hung-retry race). + const heartbeatLine = result + .split('\n') + .find((line) => line.includes('"taskId"') && line.includes('dispatchId')) + expect(heartbeatLine).toBeTruthy() + expect(heartbeatLine).toContain('task_abc123') + expect(heartbeatLine).toContain('ctx_def456') + }) + + it('includes ask block with BEHAVIOR RULE #1 forbidding AskUserQuestion', () => { + const result = buildDispatchPreamble(baseParams()) + expect(result).toContain('orchestration ask') + expect(result).toContain('--question') + expect(result).toContain('--timeout-ms 600000') + // Why: the exact phrase is asserted so the rule can't be trimmed away by + // accident. BEHAVIOR RULE #1 is the only place AskUserQuestion appears. + expect(result).toContain('BEHAVIOR RULE #1') + expect(result).toContain('NEVER use AskUserQuestion') + // AskUserQuestion must appear ONLY inside the rule text, not anywhere + // else (e.g., not in an example payload or header). Count occurrences + // of the exact token as a sanity check. + const occurrences = (result.match(/AskUserQuestion/g) ?? []).length + // Three mentions: the one-liner ban, the TUI-prompt rationale, and the + // "when tempted to reach for AskUserQuestion" closing line. + expect(occurrences).toBe(3) + }) + + it('includes AFTER YOU SEND block with 2-minute poll cadence and release signal', () => { + const result = buildDispatchPreamble(baseParams()) + expect(result).toContain('=== AFTER YOU SEND worker_done ===') + expect(result).toMatch(/2 minutes/) + expect(result).toMatch(/may exit/) + }) + + it('uses === TASK === separator with the task spec appended', () => { + const result = buildDispatchPreamble(baseParams({ taskSpec: 'refactor the auth module' })) + expect(result).toContain('=== TASK ===') expect(result).toContain('refactor the auth module') }) it('uses orca CLI by default when devMode is not set', () => { - const result = buildDispatchPreamble({ - taskId: 'task_x', - taskSpec: 'do stuff', - coordinatorHandle: 'term_c' - }) - + const result = buildDispatchPreamble(baseParams()) expect(result).toContain('orca orchestration send') expect(result).toContain('orca orchestration check') + expect(result).toContain('orca orchestration ask') }) it('uses orca-dev CLI when devMode is true', () => { - const result = buildDispatchPreamble({ - taskId: 'task_x', - taskSpec: 'do stuff', - coordinatorHandle: 'term_c', - devMode: true - }) - + const result = buildDispatchPreamble(baseParams({ devMode: true })) expect(result).toContain('orca-dev orchestration send') expect(result).toContain('orca-dev orchestration check') - // Ensure no bare "orca " (without -dev) appears as a CLI command. - // We split on "orca-dev" first so those occurrences don't produce - // false positives, then check the remaining fragments. + expect(result).toContain('orca-dev orchestration ask') const fragments = result.split('orca-dev') for (const fragment of fragments) { expect(fragment).not.toMatch(/orca orchestration/) @@ -69,14 +120,92 @@ describe('buildDispatchPreamble', () => { }) it('uses orca CLI when devMode is false', () => { - const result = buildDispatchPreamble({ - taskId: 'task_x', - taskSpec: 'do stuff', - coordinatorHandle: 'term_c', - devMode: false - }) - + const result = buildDispatchPreamble(baseParams({ devMode: false })) expect(result).toContain('orca orchestration send') expect(result).toContain('orca orchestration check') }) + + it('appends a BASE DRIFT section when baseDrift.behind > 0', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + dispatchId: 'ctx_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c', + baseDrift: { + base: 'origin/main', + behind: 7, + recentSubjects: ['fix: A', 'feat: B', 'chore: C'] + } + }) + + expect(result).toContain('--- BASE DRIFT ---') + expect(result).toContain('7 commits behind origin/main') + expect(result).toContain(' - fix: A') + expect(result).toContain(' - feat: B') + expect(result).toContain(' - chore: C') + // drift section must appear before the task spec + expect(result.indexOf('--- BASE DRIFT ---')).toBeLessThan(result.indexOf('=== TASK ===')) + }) + + it('omits the drift section when baseDrift.behind is 0', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + dispatchId: 'ctx_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c', + baseDrift: { + base: 'origin/main', + behind: 0, + recentSubjects: [] + } + }) + + expect(result).not.toContain('--- BASE DRIFT ---') + expect(result).not.toContain('commits behind') + }) + + it('omits the drift section when baseDrift is undefined', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + dispatchId: 'ctx_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c' + }) + + expect(result).not.toContain('--- BASE DRIFT ---') + expect(result).not.toContain('commits behind') + }) + + it('lists drift subjects in the order provided, each prefixed with two spaces and dash', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + dispatchId: 'ctx_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c', + baseDrift: { + base: 'origin/main', + behind: 3, + recentSubjects: ['first', 'second', 'third'] + } + }) + + const firstIdx = result.indexOf(' - first') + const secondIdx = result.indexOf(' - second') + const thirdIdx = result.indexOf(' - third') + expect(firstIdx).toBeGreaterThanOrEqual(0) + expect(secondIdx).toBeGreaterThan(firstIdx) + expect(thirdIdx).toBeGreaterThan(secondIdx) + }) + + it('renders a stable snapshot of the full preamble', () => { + // Why: single strict snapshot catches any accidental regression in + // formatting or rule presence in one line. + const result = buildDispatchPreamble({ + taskId: 'task_SNAP', + dispatchId: 'ctx_SNAP', + taskSpec: 'TASK_BODY', + coordinatorHandle: 'term_COORD' + }) + expect(result).toMatchSnapshot() + }) }) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 3c61f0be56a..6223dc94aa8 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -1,41 +1,147 @@ export type PreambleParams = { taskId: string + // Why: the heartbeat payload attributes liveness to a specific dispatch + // context (not just a task). A retried task has multiple dispatch_contexts + // rows; keying heartbeats on dispatchId prevents a straggler heartbeat from + // a previously-failed dispatch from masking a hung retry. Both call sites + // already have this value in scope (coordinator.ts dispatch.id, rpc + // orchestration.ts ctx.id). + dispatchId: string taskSpec: string coordinatorHandle: string devMode?: boolean + // Why: populated by the coordinator's dispatch pre-flight (§3.1) only + // when the target worktree is behind its tracking remote. When absent + // or when `behind === 0`, the preamble emits no drift section. Callers + // must NOT pre-populate this with empty data; the drift section is a + // loud-but-rare signal tied to the `allow-stale-base: true` override + // path, and polluting it for fresh worktrees would train workers to + // ignore it. + baseDrift?: { + base: string + behind: number + recentSubjects: string[] + } } +// Why: 5 minutes is frequent enough that the coordinator's stale-heartbeat +// check (threshold 10 min) catches a hung worker within one tick, and +// infrequent enough to avoid inbox spam on long tasks. One constant so +// cadence tuning is a single-line change (Q1 in DESIGN_DOC_PREAMBLE_FIX.md). +const HEARTBEAT_INTERVAL_MIN = 5 + // Why: the dispatch preamble teaches agents about Orca's CLI commands for -// structured communication. Agents don't need prior knowledge of Orca — they -// treat these as shell tools the same way they use git or npm. +// structured communication. Behavioral rules (body summary, heartbeat cadence, +// no-AskUserQuestion) live as inline comments above the relevant CLI example, +// not as a separate prose block — LLM readers anchor on examples and skim +// trailing prose, so rules must land at the point of use. export function buildDispatchPreamble(params: PreambleParams): string { // Why: in dev mode, agents must use orca-dev to connect to the dev runtime's // socket. Without this, agents inside the dev Electron app would call the // production CLI and talk to the wrong Orca instance (Section 6.4). const cli = params.devMode ? 'orca-dev' : 'orca' - return `You are working inside Orca, a multi-agent IDE. You have access to these -CLI commands for communicating with the coordinator: + const header = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your coordinator's terminal handle is: ${params.coordinatorHandle} +Your task ID is: ${params.taskId} - # Report task completion (REQUIRED when done): +You talk to the coordinator only through the CLI commands below. Do not use +Slack, GitHub comments, or any other channel to reach a human during the run. + +=== CLI COMMANDS === + + # Report task completion (REQUIRED when done — even on failure). + # + # RULE: --body must be a 3-sentence executive summary (what you did, + # what you found, what's left). Never send an empty body; the coordinator + # reads the body first and only opens artifacts if it needs more detail. + # If you produced a long-form artifact, include its path as + # payload.reportPath so the coordinator can find it without a file search. + # + # RULE: send worker_done exactly once. Failure is still a worker_done + # with subject like "Failed: " — never silently exit. ${cli} orchestration send --to ${params.coordinatorHandle} \\ - --type worker_done --subject "Done" \\ - --payload '{"taskId":"${params.taskId}","filesModified":[...]}' + --type worker_done --subject "" \\ + --body "<3-sentence summary: what you did, what you found, what's left>" \\ + --payload '{"taskId":"${params.taskId}","filesModified":["path/a","path/b"],"reportPath":""}' - # Report a blocker or failure: + # BEHAVIOR RULE: send a heartbeat every ${HEARTBEAT_INTERVAL_MIN} minutes + # while actively working on the task. The coordinator uses this to + # distinguish "still thinking" from "hung / crashed." Skip heartbeats only + # while blocked inside \`check --wait\` or \`ask\` — those calls are + # themselves liveness signals. + # + # Include BOTH taskId and dispatchId in the payload: the coordinator + # attributes the heartbeat to the specific dispatch context, not just + # the task, so a straggler heartbeat from a previously-failed dispatch + # cannot mask a hung retry. + ${cli} orchestration send --to ${params.coordinatorHandle} \\ + --type heartbeat --subject "alive" \\ + --payload '{"taskId":"${params.taskId}","dispatchId":"${params.dispatchId}","phase":""}' + + # Ask the coordinator a question and block until it answers. + # + # BEHAVIOR RULE #1 (MUST NOT VIOLATE): + # NEVER use AskUserQuestion; use \`${cli} orchestration ask\` or send + # --type decision_gate. AskUserQuestion opens a local TUI prompt that the + # coordinator cannot see and cannot answer — your session will hang forever + # waiting on a human. Every interactive question goes through \`ask\` below. + # + # The \`ask\` verb is a thin wrapper: it sends a decision_gate message and + # blocks on \`check --wait\` until the coordinator replies, then prints the + # reply body. Use it anywhere you would otherwise have reached for + # AskUserQuestion. + ${cli} orchestration ask --to ${params.coordinatorHandle} \\ + --question "" \\ + --options "" \\ + --timeout-ms 600000 + + # Escalate a blocker or failure (pre-completion, when you need the + # coordinator to do something before you can continue): ${cli} orchestration send --to ${params.coordinatorHandle} \\ --type escalation --subject "Blocked: " \\ - --body "
" + --body "
" \\ + --payload '{"taskId":"${params.taskId}"}' - # Check for messages from the coordinator or other agents: + # Check for messages from the coordinator: ${cli} orchestration check -Your assigned task ID is: ${params.taskId} +=== AFTER YOU SEND worker_done === -When you finish your task, run the worker_done command above with the -list of files you modified. If you are blocked or need help, send an -escalation. Do not exit the session. +Keep the shell session open for a grace period (10 minutes) in case the +coordinator sends a follow-up or re-dispatches you. Poll with +\`${cli} orchestration check\` every 2 minutes during that window. If no +follow-up arrives, you may exit after the grace period — the coordinator +will not expect further output from you. ---- TASK --- +If the coordinator re-dispatches you (you will receive a fresh preamble + +TASK block), reset your polling and start the new task. Do not respond +to the previous task's follow-ups after a re-dispatch.` + + // Why: the drift section fires only when the coordinator allowed dispatch + // against a stale worktree (via `allow-stale-base: true` in the task spec, + // see §3.4) OR when behind>0 but under the refusal threshold. Either way + // it is defense-in-depth: the worker sees the drift from line 1 instead + // of discovering it via stale line numbers in artifacts later. + const drift = + params.baseDrift && params.baseDrift.behind > 0 ? buildDriftSection(params.baseDrift) : '' + + return `${header}${drift} + +=== TASK === ${params.taskSpec}` } + +function buildDriftSection(drift: NonNullable): string { + const subjects = drift.recentSubjects.map((s) => ` - ${s}`).join('\n') + return ` + +--- BASE DRIFT --- +Your worktree HEAD is ${drift.behind} commits behind ${drift.base}. The 5 most recent +subjects on ${drift.base} NOT in your worktree: +${subjects} + +If any look relevant to your task, either pull them in (\`git pull --rebase +${drift.base}\` or equivalent) or escalate to the coordinator before starting. +---` +} diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 1d0ae9a3914..03a31391cc9 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -6,6 +6,7 @@ export type MessageType = | 'escalation' | 'handoff' | 'decision_gate' + | 'heartbeat' export type MessagePriority = 'normal' | 'high' | 'urgent' @@ -30,6 +31,7 @@ export type MessageRow = { read: number sequence: number created_at: string + delivered_at: string | null } export type TaskRow = { @@ -53,6 +55,7 @@ export type DispatchContextRow = { dispatched_at: string | null completed_at: string | null created_at: string + last_heartbeat_at: string | null } export type DecisionGateRow = { diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 9b4bae3d4e3..0340561938e 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -13,6 +13,7 @@ export type RpcSuccess = { id: string ok: true result: unknown + streaming?: true _meta: RpcEnvelopeMeta } @@ -38,6 +39,19 @@ export type RpcRequest = { export type RpcContext = { runtime: OrcaRuntimeService + // Why: long-poll handlers (e.g. orchestration.check with wait=true) need to + // observe the underlying socket's lifetime so they can release their slot + // and resolve their inner waiters immediately when a client disconnects + // instead of running down the configured timeoutMs. Undefined outside the + // runtime-rpc transport (direct in-process callers don't need it). + // See design doc §3.1 counter-lifecycle. + signal?: AbortSignal + // Why: streaming handlers (notifications/accounts/terminal subscribe) + // register cleanup callbacks against the runtime so reconnects don't leak + // listeners. Keying those cleanups by per-WebSocket connectionId lets the + // server reap all subscriptions for a closing socket, even when other + // sockets for the same deviceToken stay alive (multi-screen mobile). + connectionId?: string } export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown @@ -69,10 +83,53 @@ export function defineMethod( } } -export type RpcRegistry = ReadonlyMap +export type RpcStreamingHandler = ( + params: TParams, + ctx: RpcContext, + emit: (result: unknown) => void +) => Promise -export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry { - const registry = new Map() +// Why: streaming methods emit multiple responses over a long-lived connection. +// The `stream` flag lets the dispatcher distinguish them from one-shot methods +// and route them to the emit-based call path instead of the Promise-based one. +export type RpcStreamingMethod = { + readonly name: string + readonly params: ZodType | null + readonly stream: true + readonly handler: ( + params: unknown, + ctx: RpcContext, + emit: (result: unknown) => void + ) => Promise +} + +type DefineStreamingMethodSpec = { + name: string + params: TSchema + handler: RpcStreamingHandler +} + +export function defineStreamingMethod( + spec: DefineStreamingMethodSpec +): RpcStreamingMethod { + return { + name: spec.name, + params: spec.params, + stream: true, + handler: spec.handler as RpcStreamingMethod['handler'] + } +} + +export type RpcAnyMethod = RpcMethod | RpcStreamingMethod + +export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod { + return 'stream' in method && method.stream === true +} + +export type RpcRegistry = ReadonlyMap + +export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry { + const registry = new Map() for (const method of methods) { if (registry.has(method.name)) { throw new Error(`duplicate_rpc_method:${method.name}`) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 105fba0bc55..bdd2f181b30 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -6,8 +6,9 @@ import { ZodError, buildRegistry, formatZodError, + isStreamingMethod, + type RpcAnyMethod, type RpcEnvelopeMeta, - type RpcMethod, type RpcRegistry, type RpcRequest, type RpcResponse @@ -18,7 +19,7 @@ import type { OrcaRuntimeService } from '../orca-runtime' export type DispatcherOptions = { runtime: OrcaRuntimeService - methods?: readonly RpcMethod[] + methods?: readonly RpcAnyMethod[] } export class RpcDispatcher { @@ -30,7 +31,7 @@ export class RpcDispatcher { this.registry = buildRegistry(methods) } - async dispatch(request: RpcRequest): Promise { + async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise { const meta = this.meta() const method = this.registry.get(request.method) if (!method) { @@ -42,36 +43,121 @@ export class RpcDispatcher { ) } - let parsedParams: unknown - if (method.params === null) { - parsedParams = undefined - } else { - const rawParams = request.params ?? {} - const result = method.params.safeParse(rawParams) - if (!result.success) { - return errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error)) - } - parsedParams = result.data + const parsedParams = this.parseParams(request, method, meta) + if (parsedParams.error) { + return parsedParams.error + } + + // Why: streaming methods are not supported over one-shot transports like + // Unix sockets. They require a reply function that can be called multiple + // times, which is only available via dispatchStreaming. + if (isStreamingMethod(method)) { + return errorResponse( + request.id, + meta, + 'method_not_supported', + `Method ${request.method} requires a streaming transport` + ) } try { - const result = await method.handler(parsedParams, { runtime: this.runtime }) + const result = await method.handler(parsedParams.value, { + runtime: this.runtime, + signal: options?.signal + }) return successResponse(request.id, meta, result) } catch (error) { - // Why: browser methods throw BrowserError with a structured `code`; - // every other runtime error has a plain-message code. Routing by method - // prefix keeps the mapping a single decision rather than a per-method - // flag callers must remember to set. - if (request.method.startsWith('browser.')) { - return mapBrowserError(request.id, meta, error) - } - if (error instanceof ZodError) { - return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error)) - } - return mapRuntimeError(request.id, meta, error) + return this.mapError(request, meta, error) } } + // Why: streaming dispatch sends multiple responses through the reply callback + // instead of returning a single Promise. This enables terminal.subscribe and + // other subscription-style methods that push data over time. + async dispatchStreaming( + request: RpcRequest, + reply: (response: string) => void, + options?: { connectionId?: string } + ): Promise { + const meta = this.meta() + const method = this.registry.get(request.method) + if (!method) { + reply( + JSON.stringify( + errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`) + ) + ) + return + } + + const parsedParams = this.parseParams(request, method, meta) + if (parsedParams.error) { + reply(JSON.stringify(parsedParams.error)) + return + } + + if (!isStreamingMethod(method)) { + try { + const result = await method.handler(parsedParams.value, { + runtime: this.runtime, + connectionId: options?.connectionId + }) + reply(JSON.stringify(successResponse(request.id, meta, result))) + } catch (error) { + reply(JSON.stringify(this.mapError(request, meta, error))) + } + return + } + + const emit = (result: unknown): void => { + const response = successResponse(request.id, meta, result) + response.streaming = true + reply(JSON.stringify(response)) + } + + try { + await method.handler( + parsedParams.value, + { runtime: this.runtime, connectionId: options?.connectionId }, + emit + ) + } catch (error) { + reply(JSON.stringify(this.mapError(request, meta, error))) + } + } + + private parseParams( + request: RpcRequest, + method: RpcAnyMethod, + meta: RpcEnvelopeMeta + ): { value: unknown; error?: undefined } | { value?: undefined; error: RpcResponse } { + if (method.params === null) { + return { value: undefined } + } + const rawParams = request.params ?? {} + const result = method.params.safeParse(rawParams) + if (!result.success) { + return { + error: errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error)) + } + } + return { value: result.data } + } + + private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse { + // Why: browser methods throw BrowserError with a structured `code`; + // every other runtime error has a plain-message code. Routing by method + // prefix keeps the mapping a single decision rather than a per-method + // flag callers must remember to set. + if (request.method.startsWith('browser.')) { + return mapBrowserError(request.id, meta, error) + } + if (error instanceof ZodError) { + return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error)) + } + return mapRuntimeError(request.id, meta, error) + } + private meta(): RpcEnvelopeMeta { return { runtimeId: this.runtime.getRuntimeId() } } diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts new file mode 100644 index 00000000000..69b242c4ca8 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +function createMockWs() { + const sent: string[] = [] + return { + OPEN: 1 as const, + readyState: 1, + send: vi.fn((data: string) => sent.push(data)), + close: vi.fn(), + sent + } +} + +function setup(overrides?: Partial) { + const serverKeys = generateKeyPair() + const clientKeys = generateKeyPair() + const ws = createMockWs() + const onReady = vi.fn() + const onError = vi.fn() + + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'valid-token', + onReady, + onError, + ...overrides + }) + + return { channel, ws, serverKeys, clientKeys, onReady, onError } +} + +function doHandshake(ctx: ReturnType) { + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ctx.channel.handleRawMessage(hello) + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + ) + return sharedKey +} + +describe('E2EEChannel', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('handshake', () => { + it('completes handshake with valid encrypted auth', () => { + const ctx = setup() + doHandshake(ctx) + + expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel) + expect(ctx.onError).not.toHaveBeenCalled() + expect(ctx.channel.deviceToken).toBe('valid-token') + + const readyMsg = JSON.parse(ctx.ws.sent[0]!) + expect(readyMsg).toEqual({ type: 'e2ee_ready' }) + const authMsg = decrypt( + ctx.ws.sent[1]!, + deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ) + expect(JSON.parse(authMsg!)).toEqual({ type: 'e2ee_authenticated' }) + }) + + it('does not authenticate from plaintext hello alone', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + + expect(ctx.onReady).not.toHaveBeenCalled() + expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' }) + }) + + it('rejects invalid encrypted token', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'bad-token' }), sharedKey) + ) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Unauthorized') + expect(ctx.onReady).not.toHaveBeenCalled() + }) + + it('rejects malformed JSON', () => { + const ctx = setup() + ctx.channel.handleRawMessage('not json') + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid handshake message') + }) + + it('rejects missing fields', () => { + const ctx = setup() + ctx.channel.handleRawMessage(JSON.stringify({ type: 'e2ee_hello' })) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_hello') + }) + + it('rejects invalid public key length', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from('short').toString('base64') + }) + ) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid public key') + }) + + it('times out if no hello received', () => { + const ctx = setup() + + vi.advanceTimersByTime(10_001) + + expect(ctx.onError).toHaveBeenCalledWith(4002, 'E2EE handshake timeout') + }) + + it('clears timeout after successful handshake', () => { + const ctx = setup() + doHandshake(ctx) + + vi.advanceTimersByTime(10_001) + + expect(ctx.onError).not.toHaveBeenCalled() + }) + }) + + describe('post-handshake messaging', () => { + it('decrypts and forwards messages', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const request = '{"id":"rpc-1","method":"status.get"}' + ctx.channel.handleRawMessage(encrypt(request, sharedKey)) + + expect(received).toEqual([request]) + }) + + it('provides encrypted reply function', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + + ctx.channel.onMessage((_plaintext, encryptedReply) => { + encryptedReply('{"id":"rpc-1","ok":true}') + }) + + ctx.channel.handleRawMessage(encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey)) + + // The reply (ws.sent[2], after ready + authenticated) should be encrypted + const replyEncrypted = ctx.ws.sent[2]! + const replyPlain = decrypt(replyEncrypted, sharedKey) + expect(replyPlain).toBe('{"id":"rpc-1","ok":true}') + }) + + it('silently drops messages with wrong key', () => { + const ctx = setup() + doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const attackerKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + ctx.channel.handleRawMessage(encrypt('attack', attackerKey)) + + expect(received).toEqual([]) + }) + + it('closes after too many consecutive decrypt failures', () => { + const ctx = setup() + doHandshake(ctx) + ctx.channel.onMessage(() => {}) + + const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + expect(ctx.onError).not.toHaveBeenCalled() + + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + expect(ctx.onError).toHaveBeenCalledWith(4003, 'Too many decryption failures') + }) + + it('resets failure count on successful decrypt', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + ctx.channel.onMessage(() => {}) + + const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + + // Successful decrypt resets the counter + ctx.channel.handleRawMessage(encrypt('good', sharedKey)) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + expect(ctx.onError).not.toHaveBeenCalled() + }) + }) + + describe('cross-compatibility', () => { + it('desktop encrypt is decryptable by desktop decrypt (sanity)', () => { + const a = generateKeyPair() + const b = generateKeyPair() + const sharedA = deriveSharedKey(a.secretKey, b.publicKey) + const sharedB = deriveSharedKey(b.secretKey, a.publicKey) + + const msg = '{"method":"terminal.subscribe","params":{"terminal":"t1"}}' + const enc = encrypt(msg, sharedA) + expect(decrypt(enc, sharedB)).toBe(msg) + }) + }) + + describe('destroy', () => { + it('clears state and stops forwarding', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + ctx.channel.destroy() + ctx.channel.handleRawMessage(encrypt('after destroy', sharedKey)) + + expect(received).toEqual([]) + }) + + // Why: streaming RPC handlers (terminal.subscribe) retain the + // encryptedReply closure created inside handleRawMessage. If destroy() + // runs (mobile disconnect) before a late streaming emit, the closure's + // captured this.sharedKey is null. Without a guard, encrypt() would + // call nacl.box.after with a null key and throw an unhandled + // "unexpected type, use Uint8Array" rejection. + it('does not throw when streaming emit fires after destroy', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const sentBefore = ctx.ws.sent.length + + let capturedReply: ((response: string) => void) | null = null + ctx.channel.onMessage((_plaintext, encryptedReply) => { + capturedReply = encryptedReply + }) + + ctx.channel.handleRawMessage(encrypt('subscribe-trigger', sharedKey)) + expect(capturedReply).not.toBeNull() + + ctx.channel.destroy() + + const callLateEmit = () => capturedReply?.('late streaming frame') + expect(callLateEmit).not.toThrow() + expect(ctx.ws.sent.length).toBe(sentBefore) + }) + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts new file mode 100644 index 00000000000..f3100584d56 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -0,0 +1,185 @@ +// Why: the E2EE channel sits between the WebSocket transport and the RPC handler. +// It owns the handshake state machine and transparent encrypt/decrypt so the RPC +// handler only sees plaintext JSON, identical to the Unix socket path. +import type { WebSocket } from 'ws' +import { deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready' + +const HANDSHAKE_TIMEOUT_MS = 10_000 +const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 + +type E2EEHello = { + type: 'e2ee_hello' + publicKeyB64: string +} + +type E2EEAuth = { + type: 'e2ee_auth' + deviceToken: string +} + +export type E2EEChannelOptions = { + serverSecretKey: Uint8Array + validateToken: (token: string) => boolean + onReady: (channel: E2EEChannel) => void + onError: (code: number, reason: string) => void +} + +export class E2EEChannel { + private state: ChannelState = 'awaiting_hello' + private sharedKey: Uint8Array | null = null + private consecutiveFailures = 0 + private handshakeTimer: ReturnType | null = null + private readonly ws: WebSocket + private readonly serverSecretKey: Uint8Array + private readonly validateToken: (token: string) => boolean + private readonly onReady: (channel: E2EEChannel) => void + private readonly onError: (code: number, reason: string) => void + // Why: the RPC handler is set after the channel is ready, so the channel + // can forward decrypted messages. Kept as a callback rather than constructor + // param because the handler needs the encrypt function for replies. + private messageHandler: + | ((plaintext: string, encryptedReply: (response: string) => void) => void) + | null = null + + deviceToken: string | null = null + + constructor(ws: WebSocket, options: E2EEChannelOptions) { + this.ws = ws + this.serverSecretKey = options.serverSecretKey + this.validateToken = options.validateToken + this.onReady = options.onReady + this.onError = options.onError + + this.handshakeTimer = setTimeout(() => { + this.onError(4002, 'E2EE handshake timeout') + }, HANDSHAKE_TIMEOUT_MS) + } + + onMessage( + handler: (plaintext: string, encryptedReply: (response: string) => void) => void + ): void { + this.messageHandler = handler + } + + handleRawMessage(raw: string): void { + if (this.state === 'awaiting_hello') { + this.handleHello(raw) + return + } + + if (!this.sharedKey) { + return + } + + const plaintext = decrypt(raw, this.sharedKey) + if (plaintext === null) { + this.consecutiveFailures++ + if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + this.onError(4003, 'Too many decryption failures') + } + return + } + + this.consecutiveFailures = 0 + if (this.state === 'awaiting_auth') { + this.handleAuth(plaintext) + return + } + + // Why: streaming RPC handlers (e.g. terminal.subscribe) retain this + // closure and may fire emits long after the inbound message handled + // here. If destroy() runs in between (mobile disconnect, handshake + // failure) sharedKey becomes null and tweetnacl throws "unexpected + // type, use Uint8Array" from inside nacl.box.after. Guard both the + // socket state AND the key so late emits become silent no-ops. + const encryptedReply = (response: string) => { + if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { + return + } + this.ws.send(encrypt(response, this.sharedKey)) + } + this.messageHandler?.(plaintext, encryptedReply) + } + + private handleHello(raw: string): void { + let hello: E2EEHello + try { + hello = JSON.parse(raw) as E2EEHello + } catch { + this.onError(4001, 'Invalid handshake message') + return + } + + if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { + this.onError(4001, 'Invalid e2ee_hello') + return + } + + // Why: derive the shared key from our secret + client's public key. + // Both sides compute the same shared secret via ECDH. + const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) + if (clientPublicKey.length !== 32) { + this.onError(4001, 'Invalid public key') + return + } + + this.sharedKey = deriveSharedKey(this.serverSecretKey, clientPublicKey) + this.state = 'awaiting_auth' + + // Why: send e2ee_ready as plaintext — the client needs it to know the + // key exchange succeeded before it can send encrypted authentication. + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send(JSON.stringify({ type: 'e2ee_ready' })) + } + } + + private handleAuth(plaintext: string): void { + let auth: E2EEAuth + try { + auth = JSON.parse(plaintext) as E2EEAuth + } catch { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) + this.onError(4001, 'Invalid e2ee_auth') + return + } + + if (auth.type !== 'e2ee_auth' || !auth.deviceToken) { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) + this.onError(4001, 'Invalid e2ee_auth') + return + } + if (!this.validateToken(auth.deviceToken)) { + this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'unauthorized' } }) + this.onError(4001, 'Unauthorized') + return + } + + this.deviceToken = auth.deviceToken + this.state = 'ready' + + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + + this.sendEncryptedControl({ type: 'e2ee_authenticated' }) + this.onReady(this) + } + + private sendEncryptedControl(message: unknown): void { + if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { + this.ws.send(encrypt(JSON.stringify(message), this.sharedKey)) + } + } + + destroy(): void { + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + this.sharedKey = null + this.messageHandler = null + } +} diff --git a/src/main/runtime/rpc/e2ee-crypto.test.ts b/src/main/runtime/rpc/e2ee-crypto.test.ts new file mode 100644 index 00000000000..09e546db63c --- /dev/null +++ b/src/main/runtime/rpc/e2ee-crypto.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +describe('e2ee-crypto', () => { + it('encrypt/decrypt round-trips with shared key', () => { + const server = generateKeyPair() + const client = generateKeyPair() + + const serverShared = deriveSharedKey(server.secretKey, client.publicKey) + const clientShared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = '{"id":"rpc-1","method":"status.get"}' + const encrypted = encrypt(message, clientShared) + const decrypted = decrypt(encrypted, serverShared) + + expect(decrypted).toBe(message) + }) + + it('decrypt returns null with wrong key', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const attacker = generateKeyPair() + + const clientShared = deriveSharedKey(client.secretKey, server.publicKey) + const attackerShared = deriveSharedKey(attacker.secretKey, server.publicKey) + + const encrypted = encrypt('secret data', clientShared) + expect(decrypt(encrypted, attackerShared)).toBeNull() + }) + + it('each encryption produces unique ciphertext (random nonce)', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = 'same message' + const a = encrypt(message, shared) + const b = encrypt(message, shared) + + expect(a).not.toBe(b) + }) + + it('handles empty string', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const encrypted = encrypt('', shared) + expect(decrypt(encrypted, shared)).toBe('') + }) + + it('handles unicode content', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = '日本語テスト 🎉 émojis' + const encrypted = encrypt(message, shared) + expect(decrypt(encrypted, shared)).toBe(message) + }) + + it('decrypt returns null for truncated data', () => { + const shared = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + expect(decrypt('dG9vc2hvcnQ=', shared)).toBeNull() + }) + + it('decrypt returns null for tampered data', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const encrypted = encrypt('hello', shared) + const bytes = Buffer.from(encrypted, 'base64') + bytes[bytes.length - 1] ^= 0xff + const tampered = bytes.toString('base64') + + expect(decrypt(tampered, shared)).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/e2ee-crypto.ts b/src/main/runtime/rpc/e2ee-crypto.ts new file mode 100644 index 00000000000..39adec27160 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-crypto.ts @@ -0,0 +1,41 @@ +// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide +// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext], encoded +// as base64 for transmission over WebSocket text frames. +import nacl from 'tweetnacl' + +export function generateKeyPair(): nacl.BoxKeyPair { + return nacl.box.keyPair() +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return nacl.box.before(peerPublicKey, ourSecretKey) +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const messageBytes = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(messageBytes, nonce, sharedKey) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return Buffer.from(bundle).toString('base64') +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + + if (!plaintext) { + return null + } + + return new TextDecoder().decode(plaintext) +} diff --git a/src/main/runtime/rpc/e2ee-integration.test.ts b/src/main/runtime/rpc/e2ee-integration.test.ts new file mode 100644 index 00000000000..f4029b82473 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-integration.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel } from './e2ee-channel' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +// Why: this test simulates the full mobile → desktop E2EE flow without a real +// WebSocket. The "mobile" side generates an ephemeral keypair, sends e2ee_hello, +// the E2EEChannel (desktop) derives the shared key, and then both sides can +// exchange encrypted RPC messages. This validates that the handshake protocol +// and crypto are end-to-end compatible. + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +describe('E2EE integration (simulated mobile ↔ desktop)', () => { + let serverKeys: ReturnType + let mobileEphemeralKeys: ReturnType + let wsSent: string[] + let mockWs: { + OPEN: 1 + readyState: number + send: ReturnType + close: ReturnType + } + let channel: E2EEChannel + let onReady: (channel: E2EEChannel) => void + let onError: (code: number, reason: string) => void + + beforeEach(() => { + vi.useFakeTimers() + + serverKeys = generateKeyPair() + mobileEphemeralKeys = generateKeyPair() + wsSent = [] + mockWs = { + OPEN: 1, + readyState: 1, + send: vi.fn((data: string) => wsSent.push(data)), + close: vi.fn() + } + onReady = vi.fn() as unknown as (channel: E2EEChannel) => void + onError = vi.fn() as unknown as (code: number, reason: string) => void + + channel = new E2EEChannel(mockWs as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'device-abc', + onReady, + onError + }) + }) + + afterEach(() => { + channel.destroy() + vi.useRealTimers() + }) + + function completeHandshake(targetChannel = channel, keys = mobileEphemeralKeys): Uint8Array { + targetChannel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(keys.publicKey) + }) + ) + const sharedKey = deriveSharedKey(keys.secretKey, serverKeys.publicKey) + targetChannel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'device-abc' }), sharedKey) + ) + return sharedKey + } + + it('full handshake → encrypted RPC round-trip', () => { + const mobileShared = completeHandshake() + + // Desktop should respond with plaintext ready, then encrypted auth ack. + expect(onReady).toHaveBeenCalled() + expect(wsSent).toHaveLength(2) + expect(JSON.parse(wsSent[0]!)).toEqual({ type: 'e2ee_ready' }) + expect(JSON.parse(decrypt(wsSent[1]!, mobileShared)!)).toEqual({ + type: 'e2ee_authenticated' + }) + + // Set up the desktop message handler + const desktopReceived: string[] = [] + channel.onMessage((plaintext, encryptedReply) => { + desktopReceived.push(plaintext) + encryptedReply(JSON.stringify({ id: 'rpc-1', ok: true, result: { status: 'ready' } })) + }) + + // Mobile sends encrypted RPC request + const request = JSON.stringify({ id: 'rpc-1', method: 'status.get' }) + channel.handleRawMessage(encrypt(request, mobileShared)) + + // Desktop received the plaintext + expect(desktopReceived).toEqual([request]) + + // Desktop's encrypted reply (wsSent[2]) is decryptable by mobile + expect(wsSent).toHaveLength(3) + const replyPlain = decrypt(wsSent[2]!, mobileShared) + expect(JSON.parse(replyPlain!)).toEqual({ + id: 'rpc-1', + ok: true, + result: { status: 'ready' } + }) + }) + + it('mobile reconnects with fresh ephemeral key', () => { + // First connection + completeHandshake() + expect(onReady).toHaveBeenCalledTimes(1) + + const firstShared = deriveSharedKey(mobileEphemeralKeys.secretKey, serverKeys.publicKey) + + // Simulate disconnect + reconnect with new ephemeral key + channel.destroy() + wsSent.length = 0 + + const newMobileKeys = generateKeyPair() + const newChannel = new E2EEChannel(mockWs as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'device-abc', + onReady, + onError + }) + + completeHandshake(newChannel, newMobileKeys) + + expect(onReady).toHaveBeenCalledTimes(2) + + const secondShared = deriveSharedKey(newMobileKeys.secretKey, serverKeys.publicKey) + + // Keys from different sessions must not be interchangeable + expect(Buffer.from(firstShared).toString('hex')).not.toBe( + Buffer.from(secondShared).toString('hex') + ) + + // Verify new session works + const received: string[] = [] + newChannel.onMessage((plaintext) => received.push(plaintext)) + newChannel.handleRawMessage(encrypt('{"id":"2","method":"test"}', secondShared)) + expect(received).toEqual(['{"id":"2","method":"test"}']) + + newChannel.destroy() + }) + + it('streaming messages work through E2EE', () => { + const mobileShared = completeHandshake() + const received: string[] = [] + + channel.onMessage((plaintext, encryptedReply) => { + received.push(plaintext) + // Simulate streaming: send multiple encrypted responses + for (let i = 0; i < 3; i++) { + encryptedReply( + JSON.stringify({ + id: 'stream-1', + ok: true, + streaming: true, + result: { type: 'data', chunk: `line ${i}\n` } + }) + ) + } + }) + + channel.handleRawMessage( + encrypt(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' }), mobileShared) + ) + + // 1 plaintext ready + 1 encrypted auth ack + 3 streaming responses + expect(wsSent).toHaveLength(5) + + for (let i = 2; i < 5; i++) { + const plain = decrypt(wsSent[i]!, mobileShared) + const parsed = JSON.parse(plain!) + expect(parsed.streaming).toBe(true) + expect(parsed.result.chunk).toBe(`line ${i - 2}\n`) + } + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index e53ab52c276..da19f1cba1a 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -3,6 +3,7 @@ // format human-facing messages. Centralizing this mapping keeps the allowlist // auditable in one place instead of spread across per-method branches. import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' +import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -46,8 +47,18 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'invalid_limit' ]) +const COMPUTER_PASSTHROUGH_CODES: ReadonlySet = new Set(Object.values(COMPUTER_ERROR_CODES)) + export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { const message = error instanceof Error ? error.message : String(error) + if ( + error instanceof Error && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' && + COMPUTER_PASSTHROUGH_CODES.has((error as { code: string }).code) + ) { + return errorResponse(id, meta, (error as { code: string }).code, message) + } if (RUNTIME_PASSTHROUGH_CODES.has(message)) { return errorResponse(id, meta, message, message) } diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts new file mode 100644 index 00000000000..21612a707f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -0,0 +1,111 @@ +import { z } from 'zod' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' + +// Why: monotonically increasing per-process counter avoids the Date.now() +// collision that fired when two near-simultaneous accounts.subscribe calls +// collided on the same millisecond and one evicted the other through +// registerSubscriptionCleanup's existing-key eviction path. +let accountsSubscriptionSeq = 0 + +const SelectAccountParams = z.object({ + accountId: z + .union([z.string().min(1, 'Missing accountId'), z.null()]) + .transform((v) => (v === null ? null : v)) +}) + +const RemoveAccountParams = z.object({ + accountId: z.string().min(1, 'Missing accountId') +}) + +const AccountsUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: bridges the desktop ClaudeAccountService / CodexAccountService / +// RateLimitService into the mobile WebSocket RPC. Read + switch + remove +// only — interactive add/re-auth flows spawn `claude login` / `codex login` +// PTYs that need a desktop browser, so they intentionally remain +// desktop-only. See plan in spec doc for issue #1438. +export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ + defineMethod({ + name: 'accounts.list', + params: null, + handler: async (_params, { runtime }) => { + // Why: ensure the snapshot reflects the latest provider state before + // returning. Desktop polling pauses when the window is unfocused and + // inactive-account caches only fill on AccountsPane open, so without + // this the mobile UI would render stale nulls / zeroes. + await runtime.refreshAccountsForMobile() + return runtime.getAccountsSnapshot() + } + }), + defineMethod({ + name: 'accounts.selectClaude', + params: SelectAccountParams, + handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.selectCodex', + params: SelectAccountParams, + handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.removeClaude', + params: RemoveAccountParams, + handler: async (params, { runtime }) => runtime.removeClaudeAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.removeCodex', + params: RemoveAccountParams, + handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId) + }), + // Why: streaming counterpart so mobile usage bars refresh in place when the + // desktop's 5-minute rate-limit poll completes or when the user switches + // accounts on either side. Mirrors the notifications.subscribe pattern. + defineStreamingMethod({ + name: 'accounts.subscribe', + params: null, + handler: async (_params, { runtime, connectionId }, emit) => { + await new Promise((resolve) => { + const unsubscribe = runtime.onAccountsChanged((snapshot) => { + emit({ type: 'snapshot', snapshot }) + }) + + // Why: scope the id by connectionId so two sockets from the same + // device (host + accounts screen) cannot evict each other through + // registerSubscriptionCleanup's "existing key" branch, and append a + // per-process counter so two concurrent subscribes on the same + // socket also can't collide. + const seq = ++accountsSubscriptionSeq + const subscriptionId = `accounts-${connectionId ?? 'inproc'}-${seq}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }, + connectionId + ) + + // Why: emit the current snapshot synchronously so the phone has + // something to render immediately, then kick a forced refresh that + // will broadcast a fresh snapshot through the listener once each + // provider fetch completes. + emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() }) + void runtime.refreshAccountsForMobile() + }) + } + }), + defineMethod({ + name: 'accounts.unsubscribe', + params: AccountsUnsubscribeParams, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 5d272137300..b141c0b14a3 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -16,13 +16,19 @@ import { KeyboardInsert, Keypress, LimitParam, + ProfileCreate, + ProfileDelete, Screenshot, Scroll, Select, SelectorPath, + TabCurrent, + TabSetProfile, TabClose, TabCreate, TabList, + TabProfileClone, + TabShow, TabSwitch, Type, Upload, @@ -90,6 +96,16 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: TabList, handler: async (params, { runtime }) => runtime.browserTabList(params) }), + defineMethod({ + name: 'browser.tabShow', + params: TabShow, + handler: async (params, { runtime }) => runtime.browserTabShow(params) + }), + defineMethod({ + name: 'browser.tabCurrent', + params: TabCurrent, + handler: async (params, { runtime }) => runtime.browserTabCurrent(params) + }), defineMethod({ name: 'browser.tabSwitch', params: TabSwitch, @@ -100,11 +116,41 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: TabCreate, handler: async (params, { runtime }) => runtime.browserTabCreate(params) }), + defineMethod({ + name: 'browser.tabSetProfile', + params: TabSetProfile, + handler: async (params, { runtime }) => runtime.browserTabSetProfile(params) + }), + defineMethod({ + name: 'browser.tabProfileShow', + params: TabShow, + handler: async (params, { runtime }) => runtime.browserTabProfileShow(params) + }), + defineMethod({ + name: 'browser.tabProfileClone', + params: TabProfileClone, + handler: async (params, { runtime }) => runtime.browserTabProfileClone(params) + }), defineMethod({ name: 'browser.tabClose', params: TabClose, handler: async (params, { runtime }) => runtime.browserTabClose(params) }), + defineMethod({ + name: 'browser.profileList', + params: null, + handler: async (_params, { runtime }) => runtime.browserProfileList() + }), + defineMethod({ + name: 'browser.profileCreate', + params: ProfileCreate, + handler: async (params, { runtime }) => runtime.browserProfileCreate(params) + }), + defineMethod({ + name: 'browser.profileDelete', + params: ProfileDelete, + handler: async (params, { runtime }) => runtime.browserProfileDelete(params) + }), defineMethod({ name: 'browser.hover', params: Element, diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 3a8e3cf0ace..0a216c1d9e0 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -45,19 +45,22 @@ export const Scroll = BrowserTarget.extend({ amount: z .unknown() .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) + .optional() }) export const Screenshot = BrowserTarget.extend({ format: z .unknown() .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined)) - .pipe(z.enum(['png', 'jpeg']).optional()) + .pipe(z.union([z.enum(['png', 'jpeg']), z.undefined()])) + .optional() }) export const FullScreenshot = BrowserTarget.extend({ format: z .unknown() + .optional() .transform((v) => (v === 'jpeg' ? 'jpeg' : 'png')) .pipe(z.enum(['png', 'jpeg'])) }) @@ -73,11 +76,19 @@ export const TabList = z.object({ // Why: --index xor --page must be present. The refine guards that invariant // so the dispatcher surfaces a single legible error instead of either shape // leaking into the runtime. +// +// `focus` is opt-in: when true, the runtime sends `browser:pane-focus` to +// the renderer after the switch lands. The renderer surfaces the browser +// pane only if the user is already on the targeted worktree; otherwise it +// pre-stages per-worktree state silently. This avoids cross-worktree screen +// theft when multiple agents drive browsers in parallel worktrees. export const TabSwitch = BrowserTarget.extend({ index: z .unknown() .transform((v) => (typeof v === 'number' ? v : undefined)) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) + .optional(), + focus: z.boolean().optional() }).refine( (val) => { if (val.page !== undefined) { @@ -90,6 +101,16 @@ export const TabSwitch = BrowserTarget.extend({ export const TabCreate = z.object({ url: OptionalString, + worktree: OptionalString, + profileId: OptionalString +}) + +export const TabShow = z.object({ + page: requiredString('Missing required --page'), + worktree: OptionalString +}) + +export const TabCurrent = z.object({ worktree: OptionalString }) @@ -97,11 +118,31 @@ export const TabClose = z.object({ index: z .unknown() .transform((v) => (typeof v === 'number' ? v : undefined)) - .pipe(z.number().optional()), + .pipe(z.union([z.number(), z.undefined()])) + .optional(), page: OptionalString, worktree: OptionalString }) +export const TabSetProfile = BrowserTarget.extend({ + profileId: requiredString('Missing required --profile') +}) + +export const TabProfileClone = BrowserTarget.extend({ + profileId: requiredString('Missing required --profile') +}) + +export const ProfileCreate = z.object({ + label: requiredString('Missing required --label'), + // Strict enum so unknown scope values surface validation errors instead of being + // silently coerced to 'isolated' (pr-bug-scan finding from #1397). + scope: z.enum(['isolated', 'imported']) +}) + +export const ProfileDelete = z.object({ + profileId: requiredString('Missing required --profile') +}) + export const Drag = BrowserTarget.extend({ from: requiredString('Missing required --from and --to element refs'), to: requiredString('Missing required --from and --to element refs') @@ -120,7 +161,8 @@ export const Wait = BrowserTarget.extend({ timeout: z .unknown() .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) - .pipe(z.number().optional()), + .pipe(z.union([z.number(), z.undefined()])) + .optional(), text: OptionalPlainString, url: OptionalPlainString, load: OptionalPlainString, @@ -132,6 +174,7 @@ export const Check = BrowserTarget.extend({ element: requiredString('Missing required --element'), checked: z .unknown() + .optional() .transform((v) => v !== false) .pipe(z.boolean()) }) @@ -232,7 +275,8 @@ export const InterceptEnable = BrowserTarget.extend({ patterns: z .unknown() .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined)) - .pipe(z.array(z.string()).optional()) + .pipe(z.union([z.array(z.string()), z.undefined()])) + .optional() }) export const MouseXY = BrowserTarget.extend({ diff --git a/src/main/runtime/rpc/methods/computer.test.ts b/src/main/runtime/rpc/methods/computer.test.ts new file mode 100644 index 00000000000..4791e518240 --- /dev/null +++ b/src/main/runtime/rpc/methods/computer.test.ts @@ -0,0 +1,341 @@ +/* eslint-disable max-lines -- Why: computer RPC coverage shares one mocked registry setup across all method contracts. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildRegistry } from '../core' + +const computerMocks = vi.hoisted(() => ({ + callComputerSidecarAction: vi.fn(), + callComputerSidecarCapabilities: vi.fn(), + callComputerSidecarListApps: vi.fn(), + callComputerSidecarListWindows: vi.fn(), + callComputerSidecarSnapshot: vi.fn(), + resetComputerSidecarForTest: vi.fn(), + openComputerUsePermissions: vi.fn() +})) + +vi.mock('../../../computer/sidecar-client', () => ({ + callComputerSidecarAction: computerMocks.callComputerSidecarAction, + callComputerSidecarCapabilities: computerMocks.callComputerSidecarCapabilities, + callComputerSidecarListApps: computerMocks.callComputerSidecarListApps, + callComputerSidecarListWindows: computerMocks.callComputerSidecarListWindows, + callComputerSidecarSnapshot: computerMocks.callComputerSidecarSnapshot, + resetComputerSidecarForTest: computerMocks.resetComputerSidecarForTest +})) + +vi.mock('../../../computer/macos-computer-use-permissions', () => ({ + openComputerUsePermissions: computerMocks.openComputerUsePermissions +})) + +import { COMPUTER_METHODS, resetComputerSessionsForTest } from './computer' + +describe('computer RPC methods', () => { + beforeEach(() => { + computerMocks.callComputerSidecarAction.mockReset() + computerMocks.callComputerSidecarCapabilities.mockReset() + computerMocks.callComputerSidecarListApps.mockReset() + computerMocks.callComputerSidecarListWindows.mockReset() + computerMocks.callComputerSidecarSnapshot.mockReset() + computerMocks.resetComputerSidecarForTest.mockReset() + computerMocks.openComputerUsePermissions.mockReset() + resetComputerSessionsForTest() + computerMocks.resetComputerSidecarForTest.mockClear() + }) + + it('registers all computer methods', () => { + const registry = buildRegistry(COMPUTER_METHODS) + + expect([...registry.keys()].sort()).toEqual([ + 'computer.capabilities', + 'computer.click', + 'computer.drag', + 'computer.getAppState', + 'computer.hotkey', + 'computer.listApps', + 'computer.listWindows', + 'computer.pasteText', + 'computer.performSecondaryAction', + 'computer.permissions', + 'computer.pressKey', + 'computer.scroll', + 'computer.setValue', + 'computer.typeText' + ]) + }) + + it('resets the sidecar test process', () => { + resetComputerSessionsForTest() + + expect(computerMocks.resetComputerSidecarForTest).toHaveBeenCalledTimes(1) + }) + + it('lists running apps through the sidecar', async () => { + const result = { + apps: [{ name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }] + } + computerMocks.callComputerSidecarListApps.mockResolvedValue(result) + + await expect(call('computer.listApps', {})).resolves.toBe(result) + expect(computerMocks.callComputerSidecarListApps).toHaveBeenCalledWith() + }) + + it('returns provider capabilities through the sidecar', async () => { + const result = { platform: 'darwin', provider: 'orca-computer-use-macos', protocolVersion: 1 } + computerMocks.callComputerSidecarCapabilities.mockResolvedValue(result) + + await expect(call('computer.capabilities', {})).resolves.toBe(result) + expect(computerMocks.callComputerSidecarCapabilities).toHaveBeenCalledWith() + }) + + it('opens computer-use permission setup', async () => { + const result = { + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + openedSettings: false, + launchedHelper: true + } + computerMocks.openComputerUsePermissions.mockReturnValue(result) + + await expect(call('computer.permissions', {})).resolves.toBe(result) + expect(computerMocks.openComputerUsePermissions).toHaveBeenCalledWith() + }) + + it('lists windows through the sidecar', async () => { + const result = { + app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }, + windows: [] + } + const params = { app: 'Finder', worktree: 'path:/tmp/repo' } + computerMocks.callComputerSidecarListWindows.mockResolvedValue(result) + + await expect(call('computer.listWindows', params)).resolves.toBe(result) + expect(computerMocks.callComputerSidecarListWindows).toHaveBeenCalledWith(params) + }) + + it('gets app state through the sidecar', async () => { + const snapshot = { + snapshot: { + id: 'snap-test', + app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }, + window: { title: 'Finder', width: 100, height: 100 }, + treeText: 'tree', + elementCount: 1, + focusedElementId: null + }, + screenshot: null, + screenshotStatus: { state: 'skipped', reason: 'no_screenshot_flag' } + } + const params = { + app: 'Finder', + worktree: 'path:/tmp/repo', + noScreenshot: true, + restoreWindow: true, + windowId: 123 + } + computerMocks.callComputerSidecarSnapshot.mockResolvedValue(snapshot) + + await expect(call('computer.getAppState', params)).resolves.toBe(snapshot) + expect(computerMocks.callComputerSidecarSnapshot).toHaveBeenCalledWith(params) + }) + + it('rejects missing app in getAppState schema', () => { + const method = findMethod('computer.getAppState') + expect(() => method.params!.parse({})).toThrow() + }) + + it('rejects ambiguous window targeting', () => { + expect(() => + findMethod('computer.getAppState').params!.parse({ + app: 'Finder', + windowId: 1, + windowIndex: 0 + }) + ).toThrow(/either --window-id or --window-index/) + expect(() => + findMethod('computer.click').params!.parse({ + app: 'Finder', + elementIndex: 0, + windowId: 1, + windowIndex: 0 + }) + ).toThrow(/either --window-id or --window-index/) + }) + + it('rejects incomplete pointer action coordinates', () => { + expect(() => findMethod('computer.click').params!.parse({ app: 'Finder' })).toThrow( + /Click requires/ + ) + expect(() => findMethod('computer.click').params!.parse({ app: 'Finder', x: 1 })).toThrow( + /both --x and --y/ + ) + expect(() => + findMethod('computer.click').params!.parse({ app: 'Finder', elementIndex: 0, x: 1, y: 2 }) + ).toThrow(/either --element-index or coordinate flags/) + expect(() => + findMethod('computer.scroll').params!.parse({ app: 'Finder', direction: 'down' }) + ).toThrow(/Scroll requires/) + expect(() => + findMethod('computer.scroll').params!.parse({ + app: 'Finder', + elementIndex: 0, + x: 1, + y: 2, + direction: 'down' + }) + ).toThrow(/either --element-index or coordinate flags/) + expect(() => + findMethod('computer.drag').params!.parse({ app: 'Finder', fromX: 1, fromY: 2 }) + ).toThrow(/Drag coordinates/) + expect(() => + findMethod('computer.drag').params!.parse({ app: 'Finder', fromElementIndex: 1 }) + ).toThrow(/both --from-element-index and --to-element-index/) + expect(() => + findMethod('computer.drag').params!.parse({ + app: 'Finder', + fromElementIndex: 0, + toElementIndex: 1, + fromX: 1, + fromY: 2, + toX: 3, + toY: 4 + }) + ).toThrow(/either element indexes or coordinate flags/) + }) + + it('rejects unsupported scroll directions', () => { + expect(() => + findMethod('computer.scroll').params!.parse({ + app: 'Finder', + elementIndex: 0, + direction: 'diagonal' + }) + ).toThrow() + expect(() => + findMethod('computer.scroll').params!.parse({ + app: 'Finder', + elementIndex: 0, + direction: 'down', + pages: 0 + }) + ).toThrow() + }) + + it('dispatches pointer and element actions through the sidecar', async () => { + computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true }) + + await call('computer.click', { + app: 'Finder', + worktree: 'path:/tmp/repo', + elementIndex: 0, + clickCount: 2, + mouseButton: 'left', + noScreenshot: true + }) + await call('computer.performSecondaryAction', { + app: 'Finder', + elementIndex: 0, + action: 'Raise' + }) + await call('computer.drag', { + app: 'Finder', + fromX: 1, + fromY: 2, + toX: 3, + toY: 4 + }) + + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(1, 'click', { + app: 'Finder', + worktree: 'path:/tmp/repo', + elementIndex: 0, + clickCount: 2, + mouseButton: 'left', + noScreenshot: true + }) + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith( + 2, + 'performSecondaryAction', + { + app: 'Finder', + elementIndex: 0, + action: 'Raise' + } + ) + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(3, 'drag', { + app: 'Finder', + fromX: 1, + fromY: 2, + toX: 3, + toY: 4 + }) + }) + + it('dispatches keyboard and text actions through the sidecar', async () => { + computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true }) + + await call('computer.typeText', { app: 'Finder', text: 'hello', noScreenshot: true }) + await call('computer.pressKey', { app: 'Finder', key: 'Return' }) + await call('computer.hotkey', { app: 'Finder', key: 'CmdOrCtrl+L' }) + await call('computer.pasteText', { app: 'Finder', text: 'long text' }) + + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(1, 'typeText', { + app: 'Finder', + text: 'hello', + noScreenshot: true + }) + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(2, 'pressKey', { + app: 'Finder', + key: 'Return' + }) + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(3, 'hotkey', { + app: 'Finder', + key: 'CmdOrCtrl+L' + }) + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(4, 'pasteText', { + app: 'Finder', + text: 'long text' + }) + }) + + it('dispatches scroll and setValue actions through the sidecar', async () => { + computerMocks.callComputerSidecarAction.mockResolvedValue({ ok: true }) + + await call('computer.scroll', { + app: 'Finder', + elementIndex: 0, + direction: 'down', + pages: 2 + }) + await call('computer.setValue', { + app: 'Finder', + elementIndex: 1, + value: '' + }) + + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(1, 'scroll', { + app: 'Finder', + elementIndex: 0, + direction: 'down', + pages: 2 + }) + expect(computerMocks.callComputerSidecarAction).toHaveBeenNthCalledWith(2, 'setValue', { + app: 'Finder', + elementIndex: 1, + value: '' + }) + }) +}) + +function findMethod(name: string) { + const method = COMPUTER_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`missing method ${name}`) + } + return method +} + +async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return await method.handler(parsed, { + runtime: { getRuntimeId: () => 'runtime-1' } as never + }) +} diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts new file mode 100644 index 00000000000..88618daf64a --- /dev/null +++ b/src/main/runtime/rpc/methods/computer.ts @@ -0,0 +1,301 @@ +/* eslint-disable max-lines -- Why: computer RPC schemas and sidecar dispatch stay together so provider behavior is audited in one place. */ +import { z } from 'zod' +import { + callComputerSidecarAction, + callComputerSidecarCapabilities, + callComputerSidecarListApps, + callComputerSidecarListWindows, + callComputerSidecarSnapshot, + resetComputerSidecarForTest +} from '../../../computer/sidecar-client' +import { defineMethod, type RpcMethod } from '../core' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + requiredStringAllowingEmpty, + requiredString +} from '../schemas' + +const OptionalNonNegativeInt = z.number().int().nonnegative().optional() +const OptionalPositiveInt = z.number().int().positive().optional() + +const ComputerTarget = z.object({ + app: requiredString('Missing app'), + session: OptionalString, + worktree: OptionalPlainString +}) + +const ComputerObserveTargetBase = ComputerTarget.extend({ + noScreenshot: OptionalBoolean, + restoreWindow: OptionalBoolean, + windowId: OptionalNonNegativeInt, + windowIndex: OptionalNonNegativeInt +}) + +function validateWindowTarget( + value: { windowId?: number; windowIndex?: number }, + ctx: z.RefinementCtx +): void { + if (value.windowId !== undefined && value.windowIndex !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Window targeting accepts either --window-id or --window-index, not both' + }) + } +} + +const ComputerObserveTarget = ComputerObserveTargetBase.superRefine(validateWindowTarget) + +const ListApps = z.object({ + worktree: OptionalPlainString +}) + +const ListWindows = ComputerTarget + +const Click = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + x: OptionalFiniteNumber, + y: OptionalFiniteNumber, + clickCount: OptionalPositiveInt, + mouseButton: z.enum(['left', 'right', 'middle']).optional() +}).superRefine((value, ctx) => { + validateWindowTarget(value, ctx) + const hasElement = value.elementIndex !== undefined + const hasX = value.x !== undefined + const hasY = value.y !== undefined + if (!hasElement && !(hasX && hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Click requires --element-index or both --x and --y' + }) + } + if (hasX !== hasY) { + ctx.addIssue({ + code: 'custom', + message: 'Click coordinates require both --x and --y' + }) + } + if (hasElement && (hasX || hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Click accepts either --element-index or coordinate flags, not both' + }) + } +}) + +const PerformSecondaryAction = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + action: requiredString('Missing action') +}).superRefine((value, ctx) => { + validateWindowTarget(value, ctx) + if (value.elementIndex === undefined) { + ctx.addIssue({ code: 'custom', message: 'Missing element index' }) + } +}) + +const Scroll = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + x: OptionalFiniteNumber, + y: OptionalFiniteNumber, + direction: z.enum(['up', 'down', 'left', 'right']), + pages: z.number().positive().optional() +}).superRefine((value, ctx) => { + validateWindowTarget(value, ctx) + const hasElement = value.elementIndex !== undefined + const hasX = value.x !== undefined + const hasY = value.y !== undefined + if (!hasElement && !(hasX && hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll requires --element-index or both --x and --y' + }) + } + if (hasX !== hasY) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll coordinates require both --x and --y' + }) + } + if (hasElement && (hasX || hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll accepts either --element-index or coordinate flags, not both' + }) + } +}) + +const Drag = ComputerObserveTargetBase.extend({ + fromElementIndex: OptionalNonNegativeInt, + toElementIndex: OptionalNonNegativeInt, + fromX: OptionalFiniteNumber, + fromY: OptionalFiniteNumber, + toX: OptionalFiniteNumber, + toY: OptionalFiniteNumber +}).superRefine((value, ctx) => { + validateWindowTarget(value, ctx) + const hasElementPair = value.fromElementIndex !== undefined && value.toElementIndex !== undefined + const hasPartialElementPair = + value.fromElementIndex !== undefined || value.toElementIndex !== undefined + const coordinateKeys = [value.fromX, value.fromY, value.toX, value.toY] + const hasCoordinatePair = coordinateKeys.every((coordinate) => coordinate !== undefined) + const hasPartialCoordinatePair = coordinateKeys.some((coordinate) => coordinate !== undefined) + if (hasElementPair && hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag accepts either element indexes or coordinate flags, not both' + }) + } + if (!hasElementPair && !hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag requires --from-element-index and --to-element-index, or all coordinate flags' + }) + } + if (hasPartialElementPair && !hasElementPair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag element targeting requires both --from-element-index and --to-element-index' + }) + } + if (hasPartialCoordinatePair && !hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag coordinates require --from-x, --from-y, --to-x, and --to-y' + }) + } +}) + +const TypeText = ComputerObserveTargetBase.extend({ + text: requiredString('Missing text') +}).superRefine(validateWindowTarget) + +const PressKey = ComputerObserveTargetBase.extend({ + key: requiredString('Missing key') +}).superRefine(validateWindowTarget) + +const Hotkey = ComputerObserveTargetBase.extend({ + key: requiredString('Missing key') +}).superRefine(validateWindowTarget) + +const PasteText = ComputerObserveTargetBase.extend({ + text: requiredString('Missing text') +}).superRefine(validateWindowTarget) + +const SetValue = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + value: requiredStringAllowingEmpty('Missing value') +}).superRefine((value, ctx) => { + validateWindowTarget(value, ctx) + if (value.elementIndex === undefined) { + ctx.addIssue({ code: 'custom', message: 'Missing element index' }) + } +}) + +export function resetComputerSessionsForTest(): void { + resetComputerSidecarForTest() +} + +export const COMPUTER_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'computer.capabilities', + params: z.object({}), + handler: async () => { + return await callComputerSidecarCapabilities() + } + }), + defineMethod({ + name: 'computer.listApps', + params: ListApps, + handler: async () => { + return await callComputerSidecarListApps() + } + }), + defineMethod({ + name: 'computer.permissions', + params: z.object({}), + handler: async () => { + const { openComputerUsePermissions } = + await import('../../../computer/macos-computer-use-permissions') + return openComputerUsePermissions() + } + }), + defineMethod({ + name: 'computer.listWindows', + params: ListWindows, + handler: async (params) => { + return await callComputerSidecarListWindows(params) + } + }), + defineMethod({ + name: 'computer.getAppState', + params: ComputerObserveTarget, + handler: async (params) => { + return await callComputerSidecarSnapshot(params) + } + }), + defineMethod({ + name: 'computer.click', + params: Click, + handler: async (params) => { + return await callComputerSidecarAction('click', params) + } + }), + defineMethod({ + name: 'computer.performSecondaryAction', + params: PerformSecondaryAction, + handler: async (params) => { + return await callComputerSidecarAction('performSecondaryAction', params) + } + }), + defineMethod({ + name: 'computer.scroll', + params: Scroll, + handler: async (params) => { + return await callComputerSidecarAction('scroll', params) + } + }), + defineMethod({ + name: 'computer.drag', + params: Drag, + handler: async (params) => { + return await callComputerSidecarAction('drag', params) + } + }), + defineMethod({ + name: 'computer.typeText', + params: TypeText, + handler: async (params) => { + return await callComputerSidecarAction('typeText', params) + } + }), + defineMethod({ + name: 'computer.pressKey', + params: PressKey, + handler: async (params) => { + return await callComputerSidecarAction('pressKey', params) + } + }), + defineMethod({ + name: 'computer.hotkey', + params: Hotkey, + handler: async (params) => { + return await callComputerSidecarAction('hotkey', params) + } + }), + defineMethod({ + name: 'computer.pasteText', + params: PasteText, + handler: async (params) => { + return await callComputerSidecarAction('pasteText', params) + } + }), + defineMethod({ + name: 'computer.setValue', + params: SetValue, + handler: async (params) => { + return await callComputerSidecarAction('setValue', params) + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 9bdcc514c26..481491be99e 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,4 +1,4 @@ -import type { RpcMethod } from '../core' +import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' import { REPO_METHODS } from './repo' import { WORKTREE_METHODS } from './worktree' @@ -6,16 +6,24 @@ import { TERMINAL_METHODS } from './terminal' import { BROWSER_CORE_METHODS } from './browser-core' import { BROWSER_EXTRA_METHODS } from './browser-extras' import { ORCHESTRATION_METHODS } from './orchestration' +import { NOTIFICATION_METHODS } from './notifications' +import { STATS_METHODS } from './stats' +import { ACCOUNT_METHODS } from './accounts' +import { COMPUTER_METHODS } from './computer' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when // auditing the security boundary or wiring new CLI commands. -export const ALL_RPC_METHODS: readonly RpcMethod[] = [ +export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, ...REPO_METHODS, ...WORKTREE_METHODS, ...TERMINAL_METHODS, ...BROWSER_CORE_METHODS, ...BROWSER_EXTRA_METHODS, - ...ORCHESTRATION_METHODS + ...ORCHESTRATION_METHODS, + ...NOTIFICATION_METHODS, + ...STATS_METHODS, + ...ACCOUNT_METHODS, + ...COMPUTER_METHODS ] diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts new file mode 100644 index 00000000000..1d63f26d785 --- /dev/null +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core' + +// Why: monotonically increasing per-process counter eliminates the +// Date.now() collision that could fire when two near-simultaneous +// notifications.subscribe calls landed on the same millisecond. +let notificationsSubscriptionSeq = 0 + +const NotificationUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: notifications.subscribe streams desktop notification events to mobile +// clients over WebSocket. The mobile client shows a local push notification +// for each event. This avoids requiring Firebase/APNs — the existing +// persistent WebSocket connection doubles as the push channel. +export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ + defineStreamingMethod({ + name: 'notifications.subscribe', + params: null, + handler: async (_params, { runtime, connectionId }, emit) => { + await new Promise((resolve) => { + const unsubscribe = runtime.onNotificationDispatched((event) => { + emit({ type: 'notification', ...event }) + }) + + // Why: scope by per-ws connectionId + per-process counter so + // concurrent subscribes never collide on the cleanup map. + const seq = ++notificationsSubscriptionSeq + const subscriptionId = `notifications-${connectionId ?? 'inproc'}-${seq}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }, + connectionId + ) + + emit({ type: 'ready', subscriptionId }) + }) + } + }), + defineMethod({ + name: 'notifications.unsubscribe', + params: NotificationUnsubscribeParams, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 64a2507d0f1..3ae1d213586 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -38,7 +38,7 @@ describe('orchestration RPC methods', () => { it('registers all expected methods', () => { const registry = buildRegistry(ORCHESTRATION_METHODS) - expect(registry.size).toBe(15) + expect(registry.size).toBe(16) expect(registry.has('orchestration.send')).toBe(true) expect(registry.has('orchestration.check')).toBe(true) expect(registry.has('orchestration.reply')).toBe(true) @@ -48,6 +48,7 @@ describe('orchestration RPC methods', () => { expect(registry.has('orchestration.taskUpdate')).toBe(true) expect(registry.has('orchestration.dispatch')).toBe(true) expect(registry.has('orchestration.dispatchShow')).toBe(true) + expect(registry.has('orchestration.ask')).toBe(true) expect(registry.has('orchestration.run')).toBe(true) expect(registry.has('orchestration.runStop')).toBe(true) expect(registry.has('orchestration.gateCreate')).toBe(true) @@ -275,6 +276,79 @@ describe('orchestration RPC methods', () => { }) ).rejects.toThrow('Invalid --types') }) + + it('default (unread only) marks returned rows as read', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + + const first = (await call('orchestration.check', { terminal: 'b' })) as { + count: number + } + expect(first.count).toBe(2) + + const second = (await call('orchestration.check', { terminal: 'b' })) as { + count: number + } + expect(second.count).toBe(0) + }) + + it('--all returns every message for the handle without marking read', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + const second = db.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + db.markAsRead([second.id]) + + const result = (await call('orchestration.check', { + terminal: 'b', + all: true + })) as { messages: { read: number }[]; count: number } + + expect(result.count).toBe(2) + // Must not have flipped the remaining unread row + const stillUnread = db.getUnreadMessages('b') + expect(stillUnread).toHaveLength(1) + }) + + it('--all returns rows with delivered_at set after push-on-idle stamped them', async () => { + setup() + const msg = db.insertMessage({ from: 'a', to: 'b', subject: 'hi' }) + // Why: simulate push-on-idle stamping delivered_at without the runtime loop. + db.markAsDelivered([msg.id]) + + const result = (await call('orchestration.check', { + terminal: 'b', + all: true + })) as { messages: { id: string; delivered_at: string | null }[]; count: number } + + expect(result.count).toBe(1) + expect(result.messages[0].delivered_at).not.toBeNull() + }) + + it('--all --terminal returns empty list', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + + const result = (await call('orchestration.check', { + terminal: 'does_not_exist', + all: true + })) as { count: number } + expect(result.count).toBe(0) + }) + + it('unread:false compat shim behaves like --all (one-release bridge)', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + unread: false + })) as { count: number } + expect(result.count).toBe(1) + + // Must not have marked read + expect(db.getUnreadMessages('b')).toHaveLength(1) + }) }) describe('orchestration.reply', () => { @@ -310,6 +384,38 @@ describe('orchestration RPC methods', () => { const result = (await call('orchestration.inbox', {})) as { count: number } expect(result.count).toBe(2) }) + + it('--terminal matches check --all output for the same handle', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + db.insertMessage({ from: 'a', to: 'c', subject: 'other' }) + + const inbox = (await call('orchestration.inbox', { terminal: 'b' })) as { + messages: { id: string; to_handle: string }[] + count: number + } + const check = (await call('orchestration.check', { + terminal: 'b', + all: true + })) as { messages: { id: string; to_handle: string }[]; count: number } + + expect(inbox.count).toBe(2) + expect(check.count).toBe(2) + // Same rows in the same order — both use sequence DESC + expect(inbox.messages.map((m) => m.id)).toEqual(check.messages.map((m) => m.id)) + expect(inbox.messages.every((m) => m.to_handle === 'b')).toBe(true) + }) + + it('--terminal returns empty list without erroring', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + + const result = (await call('orchestration.inbox', { + terminal: 'does_not_exist' + })) as { count: number } + expect(result.count).toBe(0) + }) }) describe('orchestration.taskCreate', () => { @@ -369,6 +475,33 @@ describe('orchestration RPC methods', () => { const method = findMethod('orchestration.taskList') expect(() => method.params!.parse({ status: 'done-ish' })).toThrow() }) + + it('includes assignee_handle and dispatch_id for dispatched tasks', async () => { + setup() + const t1 = db.createTask({ spec: 'ready work' }) + const t2 = db.createTask({ spec: 'active work' }) + const ctx = db.createDispatchContext(t2.id, 'term_worker') + + const result = (await call('orchestration.taskList', {})) as { + tasks: { + id: string + status: string + assignee_handle?: string | null + dispatch_id?: string | null + }[] + } + + const ready = result.tasks.find((t) => t.id === t1.id) + const dispatched = result.tasks.find((t) => t.id === t2.id) + expect(ready).toBeDefined() + expect(dispatched).toBeDefined() + // Non-dispatched tasks keep the legacy shape — no assignee/dispatch fields. + expect(ready).not.toHaveProperty('assignee_handle') + expect(ready).not.toHaveProperty('dispatch_id') + // Dispatched tasks surface the active dispatch. + expect(dispatched?.assignee_handle).toBe('term_worker') + expect(dispatched?.dispatch_id).toBe(ctx.id) + }) }) describe('orchestration.taskUpdate', () => { @@ -496,6 +629,50 @@ describe('orchestration RPC methods', () => { /already has an active dispatch/ ) }) + + it('dry-run returns the preamble without mutating state', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true, + dryRun: true, + from: 'term_coord' + })) as { + dispatch: null + dryRun: boolean + preamble: string + injected: boolean + } + + expect(result.dryRun).toBe(true) + expect(result.dispatch).toBeNull() + expect(result.injected).toBe(false) + expect(result.preamble).toContain('work') + expect(result.preamble).toContain(task.id) + expect(result.preamble).toContain('term_coord') + // Task state must not change on dry-run. + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('returnPreamble includes preamble in the response', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + returnPreamble: true, + from: 'term_coord' + })) as { dispatch: { id: string }; preamble: string } + + expect(result.dispatch.id).toMatch(/^ctx_/) + expect(result.preamble).toContain(task.id) + expect(result.preamble).toContain('term_coord') + }) }) describe('orchestration.dispatchShow', () => { @@ -519,6 +696,44 @@ describe('orchestration RPC methods', () => { expect(result.dispatch).toBeNull() }) + + it('--preamble returns the preamble text', async () => { + setup() + const task = db.createTask({ spec: 'refactor auth' }) + db.createDispatchContext(task.id, 'term_a') + + const result = (await call('orchestration.dispatchShow', { + task: task.id, + preamble: true, + from: 'term_coord' + })) as { dispatch: { task_id: string } | null; preamble: string } + + expect(result.preamble).toContain('refactor auth') + expect(result.preamble).toContain(task.id) + expect(result.preamble).toContain('term_coord') + expect(result.dispatch?.task_id).toBe(task.id) + }) + + it('--preamble works when no dispatch exists yet', async () => { + setup() + const task = db.createTask({ spec: 'build feature' }) + + const result = (await call('orchestration.dispatchShow', { + task: task.id, + preamble: true, + from: 'term_coord' + })) as { dispatch: null; preamble: string } + + expect(result.dispatch).toBeNull() + expect(result.preamble).toContain('build feature') + }) + + it('--preamble throws for unknown task', async () => { + setup() + await expect( + call('orchestration.dispatchShow', { task: 'task_fake', preamble: true }) + ).rejects.toThrow('Task not found') + }) }) describe('orchestration.gateCreate', () => { @@ -621,6 +836,145 @@ describe('orchestration RPC methods', () => { }) }) + describe('orchestration.ask', () => { + it('sends a decision_gate and returns the first thread reply', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { + // Simulate coordinator replying in the thread during the wait + const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + if (outbound) { + db.insertMessage({ + from: 'term_coord', + to: 'term_worker', + subject: 'Re: Question', + body: 'go ahead', + threadId: outbound.id + }) + } + }) + + const result = (await call('orchestration.ask', { + from: 'term_worker', + to: 'term_coord', + question: 'proceed?', + options: 'yes, no', + timeoutMs: 500 + })) as { + answer: string + messageId: string + threadId: string + timedOut: boolean + } + + expect(result.timedOut).toBe(false) + expect(result.answer).toBe('go ahead') + expect(result.messageId).toMatch(/^msg_/) + + // Outbound decision_gate message was persisted with parsed options. + const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + expect(outbound).toBeTruthy() + expect(outbound?.subject).toBe('Question') + expect(outbound?.body).toBe('proceed?') + const payload = JSON.parse(outbound!.payload ?? '{}') + expect(payload.question).toBe('proceed?') + expect(payload.options).toEqual(['yes', 'no']) + }) + + it('returns timedOut when no reply arrives in the window', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue() + + const result = (await call('orchestration.ask', { + from: 'term_worker', + to: 'term_coord', + question: 'still there?', + timeoutMs: 1 + })) as { answer: string | null; timedOut: boolean; messageId: string | null } + + expect(result.timedOut).toBe(true) + expect(result.answer).toBeNull() + expect(result.messageId).toBeNull() + // Outbound message still persisted (coordinator can still see it). + const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + expect(outbound).toBeTruthy() + }) + + it('rejects group addresses with a dedicated error (no message persisted)', async () => { + setup() + await expect( + call('orchestration.ask', { + from: 'term_worker', + to: '@reviewers', + question: 'ok?' + }) + ).rejects.toThrow(/does not support group addresses/) + expect(db.getInbox(10)).toHaveLength(0) + }) + + it('does not return distractor messages on a different thread', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + let wakeCount = 0 + vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { + wakeCount++ + const outbound = db.getInbox(20).find((m) => m.type === 'decision_gate') + if (wakeCount === 1 && outbound) { + // First wake: distractor in a DIFFERENT thread — must be ignored. + db.insertMessage({ + from: 'term_coord', + to: 'term_worker', + subject: 'unrelated', + body: 'other', + threadId: 'thread_other' + }) + } else if (wakeCount === 2 && outbound) { + // Second wake: correct thread reply. + db.insertMessage({ + from: 'term_coord', + to: 'term_worker', + subject: 'Re: Question', + body: 'correct answer', + threadId: outbound.id + }) + } + }) + + const result = (await call('orchestration.ask', { + from: 'term_worker', + to: 'term_coord', + question: 'filter?', + timeoutMs: 2_000 + })) as { answer: string; timedOut: boolean } + + expect(result.timedOut).toBe(false) + expect(result.answer).toBe('correct answer') + }) + + it('parses options CSV with whitespace and empty entries', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue() + + await call('orchestration.ask', { + from: 'w', + to: 'c', + question: 'q', + options: 'a, b ,,c', + timeoutMs: 1 + }) + + const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + const payload = JSON.parse(outbound!.payload ?? '{}') + expect(payload.options).toEqual(['a', 'b', 'c']) + }) + }) + describe('orchestration.reset', () => { it('resets all state', async () => { setup() diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 5c467e369b0..e1a2d68b943 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -15,7 +15,8 @@ const MESSAGE_TYPES: MessageType[] = [ 'merge_ready', 'escalation', 'handoff', - 'decision_gate' + 'decision_gate', + 'heartbeat' ] const TASK_STATUSES: TaskStatus[] = [ @@ -40,7 +41,8 @@ const SendParams = z.object({ 'merge_ready', 'escalation', 'handoff', - 'decision_gate' + 'decision_gate', + 'heartbeat' ]) .optional(), priority: z.enum(['normal', 'high', 'urgent']).optional(), @@ -52,6 +54,10 @@ const SendParams = z.object({ const CheckParams = z.object({ terminal: OptionalString, unread: OptionalBoolean, + // Why: `all` surfaces every message for the handle and skips mark-read. + // Previously the only way to ask for "all" was the hidden RPC trick + // `{unread: false}`. See design doc §3.2 / §3.3. + all: OptionalBoolean, types: OptionalString, inject: OptionalBoolean, wait: OptionalBoolean, @@ -65,7 +71,11 @@ const ReplyParams = z.object({ }) const InboxParams = z.object({ - limit: OptionalFiniteNumber + limit: OptionalFiniteNumber, + // Why: filters the inbox listing to a specific handle so coordinators can + // ask "everything for this handle" with either `inbox` or `check --all` + // and get agreeing results. See design doc §3.3. + terminal: OptionalString }) const TaskCreateParams = z.object({ @@ -99,14 +109,30 @@ const TaskUpdateParams = z.object({ const DispatchParams = z.object({ task: requiredString('Missing --task'), - to: requiredString('Missing --to'), + // Why: --to is only required for real dispatches. When --dry-run is set the + // caller is previewing the preamble and no terminal is targeted, so allow it + // to be absent. The handler enforces presence before any side-effecting work. + to: OptionalString, from: OptionalString, inject: OptionalBoolean, + dryRun: OptionalBoolean, + returnPreamble: OptionalBoolean, devMode: OptionalBoolean }) const DispatchShowParams = z.object({ - task: OptionalString + task: OptionalString, + preamble: OptionalBoolean, + from: OptionalString, + devMode: OptionalBoolean +}) + +const AskParams = z.object({ + to: requiredString('Missing --to'), + question: requiredString('Missing --question'), + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString }) const ResetParams = z.object({ @@ -177,7 +203,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.check', params: CheckParams, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, signal }) => { const db = runtime.getOrchestrationDb() const handle = params.terminal ?? 'unknown' const typeFilter = params.types @@ -191,12 +217,17 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ throw new Error(`Invalid --types: ${invalidTypes.join(',')}`) } - const showUnread = params.unread !== false + // Why: `all` short-circuits to "everything for the handle, no marking." + // Explicit `unread: false` is also honored for one release as a compat + // shim so in-flight callers don't break (see design doc §5). Otherwise + // today's behavior is preserved: default is unread-only + mark-read. + const showAll = params.all === true || params.unread === false + const showUnread = !showAll const readAndReturn = () => { const messages = showUnread ? db.getUnreadMessages(handle, typeFilter) - : db.getAllMessages(handle) + : db.getAllMessagesForHandle(handle) if (showUnread && messages.length > 0) { db.markAsRead(messages.map((m) => m.id)) @@ -216,10 +247,15 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } // Why: blocking wait lets coordinators replace sleep+poll loops with a - // single call that resolves when a message arrives or the timeout expires. + // single call that resolves when a message arrives or the timeout + // expires. The `signal` plumbed from the RPC transport aborts this + // waiter the moment the client socket closes, so a killed client + // releases its long-poll slot immediately rather than after the full + // timeoutMs. See design doc §3.1 counter-lifecycle. await runtime.waitForMessage(handle, { typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined + timeoutMs: params.timeoutMs ?? undefined, + signal }) return readAndReturn() } @@ -255,7 +291,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ params: InboxParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() - const messages = db.getInbox(params.limit) + // Why: when `terminal` is provided, mirror `check --all` output for that + // handle (same rows in the same sequence order). Stale/unknown handles + // return an empty list instead of erroring, matching the "historical + // rows survive handle deletion" rule in design doc §3.3. + const messages = params.terminal + ? db.getAllMessagesForHandle(params.terminal, params.limit) + : db.getInbox(params.limit) return { messages, count: messages.length } } }), @@ -291,10 +333,21 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ params: TaskListParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() - const tasks = db.listTasks({ + // Why: listTasksWithDispatch returns the same rows as listTasks plus + // assignee_handle + dispatch_id joined in for tasks that currently have an + // active dispatch. Non-dispatched tasks get NULL for those fields, so + // consumers reading the legacy shape are unaffected. + const joined = db.listTasksWithDispatch({ status: params.status as TaskStatus, ready: params.ready }) + const tasks = joined.map((row) => { + const { assignee_handle, dispatch_id, ...base } = row + if (base.status === 'dispatched') { + return { ...base, assignee_handle, dispatch_id } + } + return base + }) return { tasks, count: tasks.length } } }), @@ -321,6 +374,30 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!task) { throw new Error(`Task not found: ${params.task}`) } + + // Why: --inject --dry-run lets a coordinator preview the exact preamble + // text that would be injected without mutating task state or touching the + // target terminal. Skips the ready-status check so coordinators can inspect + // the preamble for already-dispatched or blocked tasks too. No dispatch + // context exists yet (that happens after the ready-status check), so + // dispatchId is a placeholder — the real injected preamble gets a real + // ctx.id below. + if (params.dryRun) { + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: 'ctx_dryrun', + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + devMode: params.devMode + }) + return { dispatch: null, injected: false, dryRun: true, preamble } + } + + if (!params.to) { + throw new Error('Missing --to') + } + const to = params.to + if (task.status !== 'ready') { throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) } @@ -330,28 +407,34 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ // status and foreground process — Claude Code doesn't emit recognized OSC // titles on startup, so title-only detection misses freshly spawned agents. if (params.inject) { - const hasAgent = await runtime.isTerminalRunningAgent(params.to) + const hasAgent = await runtime.isTerminalRunningAgent(to) if (!hasAgent) { throw new Error( - `Cannot dispatch --inject to terminal ${params.to}: no recognized agent detected. ` + + `Cannot dispatch --inject to terminal ${to}: no recognized agent detected. ` + 'Start an agent CLI (e.g. claude, codex, gemini) in the terminal first, ' + 'or dispatch without --inject and send the prompt manually.' ) } } - const ctx = db.createDispatchContext(params.task, params.to) + const ctx = db.createDispatchContext(params.task, to) + + // Why: preamble is built here (not before ctx) so `dispatchId` can be + // the real ctx.id — the preamble-hardening PR made dispatchId required + // so heartbeats can attribute liveness to a specific dispatch context, + // not just a task. + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: ctx.id, + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + devMode: params.devMode + }) let injected = false if (params.inject) { try { - const preamble = buildDispatchPreamble({ - taskId: task.id, - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - devMode: params.devMode - }) - await runtime.sendTerminal(params.to, { text: preamble, enter: true }) + await runtime.sendTerminal(to, { text: preamble, enter: true }) injected = true } catch (err) { db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) @@ -359,6 +442,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } } + // Why: returnPreamble is opt-in because the preamble is several hundred + // bytes and most callers don't need it in the response. Exposing it + // supports coordinators that want to log what was injected for auditing. + if (params.returnPreamble) { + return { dispatch: ctx, injected, preamble } + } return { dispatch: ctx, injected } } }), @@ -372,10 +461,100 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ throw new Error('Missing --task') } const ctx = db.getDispatchContext(params.task) + + // Why: --preamble lets callers inspect the exact preamble text that was + // (or would be) injected for this task. The preamble is derived from the + // current task spec, so even after dispatch completes the text can be + // regenerated deterministically. + if (params.preamble) { + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const preamble = buildDispatchPreamble({ + taskId: task.id, + // Why: prefer the existing dispatch context's id if we have one + // (so the preview matches what was actually injected); fall back + // to a placeholder when no dispatch has occurred yet. + dispatchId: ctx?.id ?? 'ctx_preview', + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + devMode: params.devMode + }) + return { dispatch: ctx ?? null, preamble } + } + return { dispatch: ctx ?? null } } }), + defineMethod({ + name: 'orchestration.ask', + params: AskParams, + handler: async (params, { runtime }) => { + // Why: group addresses have no unambiguous answer semantics (whose + // reply wins? first? consensus?) and the ~60-LOC scope is not the + // place to design that. Rejecting here closes the silent-timeout + // footgun where a worker passing `--to @reviewers` would have the + // decision_gate inserted against a literal string no one subscribes + // to. Workers that need fan-out fall back to `send --type decision_gate`. + if (isGroupAddress(params.to)) { + throw new Error( + 'ask does not support group addresses; use send --type decision_gate for fan-out questions' + ) + } + + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + const timeoutMs = params.timeoutMs ?? 600_000 + const options = + params.options + ?.split(',') + .map((s) => s.trim()) + .filter(Boolean) ?? [] + + const payload = JSON.stringify({ question: params.question, options }) + const outbound = db.insertMessage({ + from, + to: params.to, + subject: 'Question', + body: params.question, + type: 'decision_gate', + payload + }) + runtime.deliverPendingMessagesForHandle(params.to) + runtime.notifyMessageArrived(params.to) + + const threadId = outbound.id + const deadline = Date.now() + timeoutMs + const afterSequence = outbound.sequence + + // Why: loop with a remaining-budget guard so an unrelated distractor + // message that wakes waitForMessage does not cause indefinite iteration. + // waitForMessage is handle-scoped, so we re-query by thread on every + // wake-up to separate "reply in my thread arrived" from "something + // else was delivered to this handle." + while (true) { + const replies = db.getThreadMessagesFor(threadId, from, afterSequence) + if (replies.length > 0) { + const reply = replies[0] + db.markAsRead([reply.id]) + return { + answer: reply.body, + messageId: reply.id, + threadId, + timedOut: false + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { answer: null, messageId: null, threadId, timedOut: true } + } + await runtime.waitForMessage(from, { timeoutMs: remainingMs }) + } + } + }), + ...ORCHESTRATION_GATE_METHODS, defineMethod({ diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index b3a851dac0d..3af1133f836 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -52,5 +52,10 @@ export const REPO_METHODS: RpcMethod[] = [ params: RepoSearchRefs, handler: async (params, { runtime }) => runtime.searchRepoRefs(params.repo, params.query, params.limit) + }), + defineMethod({ + name: 'repo.hooks', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo) }) ] diff --git a/src/main/runtime/rpc/methods/stats.ts b/src/main/runtime/rpc/methods/stats.ts new file mode 100644 index 00000000000..59f71701c3a --- /dev/null +++ b/src/main/runtime/rpc/methods/stats.ts @@ -0,0 +1,11 @@ +import { defineMethod, type RpcMethod } from '../core' + +export const STATS_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'stats.summary', + params: null, + handler: async (_params, { runtime }) => { + return runtime.getStatsSummary() ?? {} + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index cc3db888eb9..fbf4136aa7a 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,7 +1,16 @@ +/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +// Why: when a mobile client subscribes the server resizes the PTY to phone +// dims and serializes the buffer. Sending only the visible screen meant +// users coming back to the app or switching terminals could no longer scroll +// up to see prior agent output. Include enough scrollback to keep typical +// agent runs (Claude Code chats, command output) reachable. The mobile +// WebView's xterm has a 5000-row buffer so this fits comfortably. +const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 + const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) @@ -35,6 +44,7 @@ const TerminalRead = TerminalHandle.extend({ message: 'Cursor must be a non-negative integer' }) ) + .optional() }) // Why: the legacy handler allowed `title: string | null` and rejected every @@ -49,7 +59,18 @@ const TerminalRename = TerminalHandle.extend({ const TerminalSend = TerminalHandle.extend({ text: OptionalString, enter: z.unknown().optional(), - interrupt: z.unknown().optional() + interrupt: z.unknown().optional(), + // Why: identifies the caller for the driver state machine. Optional for + // backward compatibility with older mobile clients (server falls back to + // the most recent mobile actor when absent). New mobile builds populate + // this so multi-mobile semantics resolve correctly. See + // docs/mobile-presence-lock.md. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional() }) const TerminalWait = TerminalHandle.extend({ @@ -62,14 +83,16 @@ const TerminalWait = TerminalHandle.extend({ const TerminalCreateParams = z.object({ worktree: OptionalString, command: OptionalString, - title: OptionalString + title: OptionalString, + focus: z.unknown().optional() }) const TerminalSplit = TerminalHandle.extend({ direction: z .unknown() .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) - .pipe(z.enum(['vertical', 'horizontal']).optional()), + .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) + .optional(), command: OptionalString }) @@ -77,7 +100,104 @@ const TerminalStop = z.object({ worktree: requiredString('Missing worktree selector') }) -export const TERMINAL_METHODS: RpcMethod[] = [ +const TerminalResizeForClient = z.discriminatedUnion('mode', [ + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('mobile-fit'), + cols: z.number().finite().positive(), + rows: z.number().finite().positive(), + clientId: requiredString('Missing client ID') + }), + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('restore'), + clientId: requiredString('Missing client ID') + }) +]) + +const TerminalSubscribe = TerminalHandle.extend({ + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: z + .object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }) + .optional() +}) + +const TerminalSetDisplayMode = TerminalHandle.extend({ + // Why: 'phone' was previously a "stay at phone dims after unsubscribe" + // mode that the toggle UI never produced and nothing in product + // depended on. Removed in favor of two clean modes: 'auto' (mobile + // drives dims while subscribed, desktop restores on last-leave) and + // 'desktop' (no resize, mobile scales the wide canvas down to fit). + mode: z.enum(['auto', 'desktop']), + // Why: identifies the caller for the driver state machine. Optional for + // backward compatibility with older mobile clients. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + // Why: subscribers that registered before viewport was measured have + // a null viewport on their record. Toggling to 'auto' would no-op + // because applyMobileDisplayMode skips phone-fit when viewport is + // missing. Allow the toggle to carry the latest measured viewport so + // the server can store it on the subscriber record before fitting. + viewport: z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive() + }) + .optional() +}) + +const TerminalUnsubscribe = z.object({ + subscriptionId: requiredString('Missing subscription ID'), + // Why: required when subscribe registered the cleanup under the composite + // key `${terminal}:${clientId}`. If the caller passes a bare-handle + // subscriptionId (older clients), the server reconstructs the composite + // key from `client.id`. See docs/mobile-presence-lock.md. + client: z + .object({ + id: requiredString('Missing client ID') + }) + .optional() +}) + +// Why: in-place viewport update for an existing mobile subscription. Used +// when the keyboard opens/closes on the mobile client and the visible +// terminal area changes — without this, the mobile app had to +// unsubscribe → resubscribe, which (a) flashed the desktop lock banner +// during the brief idle gap and (b) caused the new subscribe to capture +// the already-phone-fitted PTY size as its restore baseline, leaving the +// PTY stuck at phone dims after the phone disconnected. See +// docs/mobile-presence-lock.md. +const TerminalUpdateViewport = TerminalHandle.extend({ + client: z.object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('mobile').optional() + }), + viewport: z.object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }) +}) + +// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md). `null` +// means Indefinite; finite millisecond values are clamped server-side +// into [5_000, 60min] before persistence. +const TerminalSetAutoRestoreFit = z.object({ + ms: z.number().nullable() +}) + +export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.list', params: TerminalListParams, @@ -114,13 +234,24 @@ export const TERMINAL_METHODS: RpcMethod[] = [ defineMethod({ name: 'terminal.send', params: TerminalSend, - handler: async (params, { runtime }) => ({ - send: await runtime.sendTerminal(params.terminal, { + handler: async (params, { runtime }) => { + const result = await runtime.sendTerminal(params.terminal, { text: params.text, enter: params.enter === true, interrupt: params.interrupt === true }) - }) + // Why: deliberate mobile input is a take-floor action. Drives the + // `* → mobile{clientId}` driver transition so the desktop banner + // remounts (if previously reclaimed) and active phone-fit dims follow + // the most recent actor. Only mobile-typed callers take the floor. + if (params.client && params.client.type === 'mobile') { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (leaf?.ptyId) { + await runtime.mobileTookFloor(leaf.ptyId, params.client.id) + } + } + return { send: result } + } }), defineMethod({ name: 'terminal.wait', @@ -138,7 +269,8 @@ export const TERMINAL_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => ({ terminal: await runtime.createTerminal(params.worktree, { command: params.command, - title: params.title + title: params.title, + focus: params.focus === true }) }) }), @@ -157,6 +289,29 @@ export const TERMINAL_METHODS: RpcMethod[] = [ params: TerminalStop, handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) }), + defineMethod({ + name: 'terminal.resizeForClient', + params: TerminalResizeForClient, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const result = await runtime.resizeForClient( + leaf.ptyId, + params.mode, + params.clientId, + params.mode === 'mobile-fit' ? params.cols : undefined, + params.mode === 'mobile-fit' ? params.rows : undefined + ) + return { + terminal: { + handle: params.terminal, + ...result + } + } + } + }), defineMethod({ name: 'terminal.focus', params: TerminalHandle, @@ -170,5 +325,211 @@ export const TERMINAL_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => ({ close: await runtime.closeTerminal(params.terminal) }) + }), + defineMethod({ + name: 'terminal.setDisplayMode', + params: TerminalSetDisplayMode, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + // Why: late-bind viewport for callers that subscribed in desktop + // mode (no viewport stored). Without this, a 'auto' toggle on a + // viewport-less record skips phone-fit and the user sees no resize. + if (params.viewport && params.client?.id) { + runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) + } + runtime.setMobileDisplayMode(leaf.ptyId, params.mode) + await runtime.applyMobileDisplayMode(leaf.ptyId) + // Why: a deliberate mobile mode change is a take-floor action when + // moving to auto/phone (the user explicitly chose to drive at phone + // dims). Setting mode to desktop is intentionally NOT a take-floor + // action — that's a "watch from desktop dims" gesture. + if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { + await runtime.mobileTookFloor(leaf.ptyId, params.client.id) + } + return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } + } + }), + defineMethod({ + name: 'terminal.getDisplayMode', + params: TerminalHandle, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' + const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false + return { mode, isPhoneFitted } + } + }), + defineMethod({ + name: 'terminal.updateViewport', + params: TerminalUpdateViewport, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const updated = await runtime.updateMobileViewport( + leaf.ptyId, + params.client.id, + params.viewport + ) + return { updated, seq: runtime.getLayout(leaf.ptyId)?.seq } + } + }), + // Why: terminal.subscribe streams live terminal output over WebSocket. + // It sends initial scrollback, then live data chunks as they arrive. + // Mobile clients pass client+viewport params for server-side auto-fit. + defineStreamingMethod({ + name: 'terminal.subscribe', + params: TerminalSubscribe, + handler: async (params, { runtime }, emit) => { + let leaf = runtime.resolveLeafForHandle(params.terminal) + const isMobile = params.client?.type === 'mobile' + + // Why: the left pane's PTY spawns asynchronously after the tab is created. + // Mobile clients that subscribe before the PTY is ready would get a bare + // scrollback+end with no live stream or phone-fit. Wait for the PTY so + // the subscribe can proceed normally. + if (!leaf?.ptyId && isMobile) { + try { + const ptyId = await runtime.waitForLeafPtyId(params.terminal) + leaf = { ptyId } + } catch { + // PTY wait timed out — fall through to scrollback-only path below + } + } + + if (!leaf?.ptyId) { + const read = await runtime.readTerminal(params.terminal) + emit({ + type: 'scrollback', + lines: read.tail, + truncated: read.truncated, + serialized: undefined, + cols: undefined, + rows: undefined + }) + emit({ type: 'end' }) + return + } + + const ptyId = leaf.ptyId + const clientId = params.client?.id + + // Server-side auto-fit: resize PTY to phone dims before serializing scrollback + if (isMobile && clientId) { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } + + const read = await runtime.readTerminal(params.terminal) + const serialized = await runtime.serializeTerminalBuffer(ptyId, { + scrollbackRows: isMobile ? MOBILE_SUBSCRIBE_SCROLLBACK_ROWS : 0 + }) + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + // Why: emit the current layout seq with the initial scrollback so + // the mobile client's stale-event filter knows the high-water mark. + // Undefined when the PTY has never transitioned (filter is fail-open). + // See docs/mobile-terminal-layout-state-machine.md. + const seq = runtime.getLayout(ptyId)?.seq + emit({ + type: 'scrollback', + lines: read.tail, + truncated: read.truncated, + serialized: serialized?.data, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq + }) + + await new Promise((resolve) => { + const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + emit({ type: 'data', chunk: data }) + }) + + // Inline resize events replace the old fit-override-changed event for + // mobile clients. They include fresh serialized scrollback so the client + // can reinitialize xterm without resubscribing. + const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, async (event) => { + // Why: mobile subscriptions need the same scrollback on inline + // resize as on initial subscribe — without it, toggling phone/desktop + // mode or the keyboard-driven refit would silently wipe history. + const fresh = await runtime.serializeTerminalBuffer(ptyId, { + scrollbackRows: isMobile ? MOBILE_SUBSCRIBE_SCROLLBACK_ROWS : 0 + }) + emit({ + type: 'resized', + cols: event.cols, + rows: event.rows, + serialized: fresh?.data, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + }) + + // Legacy fit-override-changed for non-mobile (desktop) subscribers + const unsubscribeFit = !isMobile + ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + emit({ + type: 'fit-override-changed', + mode: event.mode, + cols: event.cols, + rows: event.rows + }) + }) + : () => {} + + // Why: composite subscriptionId per (terminal, clientId) so two + // mobile clients subscribing to the same terminal handle do not + // evict each other via registerSubscriptionCleanup's + // duplicate-key cleanup. See docs/mobile-presence-lock.md. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + runtime.registerSubscriptionCleanup(subscriptionId, () => { + unsubscribeData() + unsubscribeResize() + unsubscribeFit() + if (isMobile && clientId) { + runtime.handleMobileUnsubscribe(ptyId, clientId) + } + emit({ type: 'end' }) + resolve() + }) + }) + } + }), + defineMethod({ + name: 'terminal.unsubscribe', + params: TerminalUnsubscribe, + handler: async (params, { runtime }) => { + // Why: the subscribe handler now registers cleanup under a composite + // key `${terminal}:${clientId}`. New mobile builds emit the composite + // key directly. Older builds emit a bare-handle subscriptionId; if + // they additionally provide `client.id`, reconstruct the composite + // key server-side. We always try the as-sent value first, then fall + // back to the reconstructed composite, so both wire formats work. + runtime.cleanupSubscription(params.subscriptionId) + if (params.client && !params.subscriptionId.includes(':')) { + runtime.cleanupSubscription(`${params.subscriptionId}:${params.client.id}`) + } + return { unsubscribed: true } + } + }), + defineMethod({ + name: 'terminal.getAutoRestoreFit', + params: z.object({}), + handler: async (_params, { runtime }) => ({ + ms: runtime.getMobileAutoRestoreFitMs() + }) + }), + defineMethod({ + name: 'terminal.setAutoRestoreFit', + params: TerminalSetAutoRestoreFit, + handler: async (params, { runtime }) => ({ + ms: runtime.setMobileAutoRestoreFitMs(params.ms) + }) }) ] diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 8f5bd832a5e..e3358fa731b 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -28,20 +28,29 @@ const WorktreeCreate = z.object({ .unknown() .transform((v) => (typeof v === 'string' ? v : '')) .pipe(z.string().min(1, 'Missing repo selector')), - name: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree name')), + name: OptionalString, baseBranch: OptionalString, linkedIssue: TriStateLinkedIssue, comment: OptionalString, - runHooks: OptionalBoolean + runHooks: OptionalBoolean, + activate: OptionalBoolean, + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) + .optional(), + // Why: mobile clients pass a startup command (e.g. 'claude') so the first + // terminal pane launches the selected agent instead of an idle shell. + startupCommand: OptionalString }) const WorktreeSet = WorktreeSelector.extend({ displayName: OptionalString, linkedIssue: TriStateLinkedIssue, - comment: OptionalString + comment: OptionalString, + isPinned: OptionalBoolean }) const WorktreeRemove = WorktreeSelector.extend({ @@ -67,17 +76,30 @@ export const WORKTREE_METHODS: RpcMethod[] = [ worktree: await runtime.showManagedWorktree(params.worktree) }) }), + defineMethod({ + name: 'worktree.sleep', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree) + }), + defineMethod({ + name: 'worktree.activate', + params: WorktreeSelector, + handler: async (params, { runtime }) => runtime.activateManagedWorktree(params.worktree) + }), defineMethod({ name: 'worktree.create', params: WorktreeCreate, handler: async (params, { runtime }) => runtime.createManagedWorktree({ repoSelector: params.repo, - name: params.name, + name: params.name ?? '', baseBranch: params.baseBranch, linkedIssue: params.linkedIssue, comment: params.comment, - runHooks: params.runHooks === true + runHooks: params.runHooks === true, + activate: params.activate === true, + setupDecision: params.setupDecision, + startup: params.startupCommand ? { command: params.startupCommand } : undefined }) }), defineMethod({ @@ -87,7 +109,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { displayName: params.displayName, linkedIssue: params.linkedIssue, - comment: params.comment + comment: params.comment, + isPinned: params.isPinned }) }) }), diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts new file mode 100644 index 00000000000..e68b3a572dd --- /dev/null +++ b/src/main/runtime/rpc/schemas.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { z, type ZodType } from 'zod' +import { + BrowserTarget, + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalPositiveInt, + OptionalString +} from './schemas' +import { + InterceptEnable, + Screenshot, + Scroll, + TabClose, + TabSwitch, + Wait +} from './methods/browser-schemas' +import { TERMINAL_METHODS } from './methods/terminal' +import { WORKTREE_METHODS } from './methods/worktree' + +function expectParses(schema: ZodType, value: unknown): void { + const result = schema.safeParse(value) + expect(result.success, result.success ? undefined : JSON.stringify(result.error.issues)).toBe( + true + ) +} + +function methodParams( + methods: readonly { name: string; params: ZodType | null }[], + name: string +): ZodType { + const method = methods.find((candidate) => candidate.name === name) + if (!method?.params) { + throw new Error(`missing test method schema: ${name}`) + } + return method.params +} + +describe('RPC optional pipe schemas', () => { + it('accepts omitted shared optional helper fields', () => { + const schema = z.object({ + finite: OptionalFiniteNumber, + positive: OptionalPositiveInt, + string: OptionalString, + plain: OptionalPlainString, + boolean: OptionalBoolean + }) + + expectParses(schema, {}) + }) + + it('accepts omitted browser optional fields while required fields are present', () => { + expectParses(Scroll, { direction: 'down' }) + expectParses(Screenshot, {}) + expectParses(TabSwitch, { page: 'page-1' }) + expectParses(TabClose, {}) + expectParses(Wait, {}) + expectParses(InterceptEnable, {}) + expectParses(BrowserTarget, {}) + }) + + it('accepts omitted terminal and worktree optional fields while required fields are present', () => { + expectParses(methodParams(TERMINAL_METHODS, 'terminal.split'), { terminal: 'terminal-1' }) + expectParses(methodParams(WORKTREE_METHODS, 'worktree.create'), { repo: 'repo-1' }) + }) +}) diff --git a/src/main/runtime/rpc/schemas.ts b/src/main/runtime/rpc/schemas.ts index 689585a5b85..5324695c090 100644 --- a/src/main/runtime/rpc/schemas.ts +++ b/src/main/runtime/rpc/schemas.ts @@ -8,32 +8,39 @@ import { z } from 'zod' // Why: the original handlers treated non-numeric/NaN limit values as "no // limit" rather than as errors. Preserve that forgiving behavior so CLI // callers passing stringified numbers or Infinity still reach the runtime. +// The outer optional() is required for omitted keys in Zod v4; an optional +// schema hidden behind pipe() still makes z.object require the property. export const OptionalFiniteNumber = z .unknown() .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) + .optional() export const OptionalPositiveInt = z .unknown() .transform((value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined ) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) + .optional() export const OptionalString = z .unknown() .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined)) - .pipe(z.string().optional()) + .pipe(z.union([z.string(), z.undefined()])) + .optional() export const OptionalPlainString = z .unknown() .transform((value) => (typeof value === 'string' ? value : undefined)) - .pipe(z.string().optional()) + .pipe(z.union([z.string(), z.undefined()])) + .optional() export const OptionalBoolean = z .unknown() .transform((value) => (typeof value === 'boolean' ? value : undefined)) - .pipe(z.boolean().optional()) + .pipe(z.union([z.boolean(), z.undefined()])) + .optional() // Why: runtime handlers accept `linkedIssue: number | null | undefined` with // distinct meanings — undefined means "no update", null means "clear", number @@ -50,6 +57,7 @@ export const TriStateLinkedIssue = z return undefined }) .pipe(z.union([z.number(), z.null(), z.undefined()])) + .optional() // Why: the legacy extractBrowserTarget treated worktree as a plain-string // passthrough (empty string preserved) but `page` as non-empty-string. The @@ -69,6 +77,10 @@ export function requiredString(message: string) { .pipe(z.string().min(1, message)) } +export function requiredStringAllowingEmpty(message: string) { + return z.unknown().refine((value): value is string => typeof value === 'string', { message }) +} + export function requiredNumber(message: string) { return z .unknown() diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts new file mode 100644 index 00000000000..7afa5ae7553 --- /dev/null +++ b/src/main/runtime/rpc/streaming.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { RpcDispatcher } from './dispatcher' +import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('RpcDispatcher streaming', () => { + it('sends initial scrollback via emit', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime({ + readTerminal: vi.fn().mockResolvedValue({ tail: 'hello\nworld\n', truncated: false }), + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }) + }), + methods: [ + defineStreamingMethod({ + name: 'terminal.subscribe', + params: z.object({ terminal: z.string() }), + handler: async (params, { runtime }, emit) => { + const read = await (runtime as OrcaRuntimeService).readTerminal(params.terminal) + emit({ type: 'scrollback', lines: read.tail, truncated: read.truncated }) + + const leaf = (runtime as OrcaRuntimeService).resolveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + emit({ type: 'end' }) + } + } + }) + ] + }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { terminal: 'h-1' }), + (msg) => messages.push(msg) + ) + + expect(messages).toHaveLength(2) + const scrollback = JSON.parse(messages[0]!) + expect(scrollback).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'scrollback', lines: 'hello\nworld\n', truncated: false } + }) + const end = JSON.parse(messages[1]!) + expect(end).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'end' } + }) + }) + + it('streams live data chunks via emit', async () => { + const messages: string[] = [] + let emitFn: ((result: unknown) => void) | null = null + let resolveHandler: (() => void) | null = null + + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.stream', + params: null, + handler: async (_params, _ctx, emit) => { + emitFn = emit + await new Promise((resolve) => { + resolveHandler = resolve + }) + } + }) + ] + }) + + const dispatchPromise = dispatcher.dispatchStreaming(makeRequest('test.stream'), (msg) => + messages.push(msg) + ) + + // Wait for handler to capture emit + await vi.waitFor(() => expect(emitFn).not.toBeNull()) + + emitFn!({ type: 'data', chunk: 'line 1\n' }) + emitFn!({ type: 'data', chunk: 'line 2\n' }) + emitFn!({ type: 'end' }) + resolveHandler!() + + await dispatchPromise + + expect(messages).toHaveLength(3) + expect(JSON.parse(messages[0]!)).toMatchObject({ + streaming: true, + result: { type: 'data', chunk: 'line 1\n' } + }) + expect(JSON.parse(messages[1]!)).toMatchObject({ + streaming: true, + result: { type: 'data', chunk: 'line 2\n' } + }) + expect(JSON.parse(messages[2]!)).toMatchObject({ + streaming: true, + result: { type: 'end' } + }) + }) + + it('unsubscribe stops further streaming', async () => { + const messages: string[] = [] + let cleanup: (() => void) | null = null + + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime({ + registerSubscriptionCleanup: vi.fn().mockImplementation((_id: string, fn: () => void) => { + cleanup = fn + }), + cleanupSubscription: vi.fn().mockImplementation(() => { + cleanup?.() + }) + }), + methods: [ + defineStreamingMethod({ + name: 'test.subscribe', + params: null, + handler: async (_params, { runtime }, emit) => { + emit({ type: 'scrollback', lines: '' }) + + await new Promise((resolve) => { + ;(runtime as OrcaRuntimeService).registerSubscriptionCleanup('sub-1', () => { + emit({ type: 'end' }) + resolve() + }) + }) + } + }), + defineMethod({ + name: 'test.unsubscribe', + params: z.object({ subscriptionId: z.string() }), + handler: async (params, { runtime }) => { + ;(runtime as OrcaRuntimeService).cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) + ] + }) + + const subPromise = dispatcher.dispatchStreaming(makeRequest('test.subscribe'), (msg) => + messages.push(msg) + ) + + await vi.waitFor(() => expect(cleanup).not.toBeNull()) + + const unsubMessages: string[] = [] + await dispatcher.dispatchStreaming( + { + id: 'req-unsub', + authToken: 'tok', + method: 'test.unsubscribe', + params: { subscriptionId: 'sub-1' } + }, + (msg) => unsubMessages.push(msg) + ) + + await subPromise + + expect(unsubMessages).toHaveLength(1) + expect(JSON.parse(unsubMessages[0]!)).toMatchObject({ + ok: true, + result: { unsubscribed: true } + }) + + const streamMessages = messages.map((m) => JSON.parse(m)) + expect(streamMessages).toContainEqual( + expect.objectContaining({ result: { type: 'scrollback', lines: '' } }) + ) + expect(streamMessages).toContainEqual(expect.objectContaining({ result: { type: 'end' } })) + }) + + it('falls back to one-shot dispatch for non-streaming methods via dispatchStreaming', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineMethod({ + name: 'status.get', + params: null, + handler: async () => ({ status: 'ok' }) + }) + ] + }) + + await dispatcher.dispatchStreaming(makeRequest('status.get'), (msg) => messages.push(msg)) + + expect(messages).toHaveLength(1) + const response = JSON.parse(messages[0]!) + expect(response).toMatchObject({ ok: true, result: { status: 'ok' } }) + expect(response.streaming).toBeUndefined() + }) + + it('returns error for unknown method via dispatchStreaming', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [] + }) + + await dispatcher.dispatchStreaming(makeRequest('nonexistent.method'), (msg) => + messages.push(msg) + ) + + expect(messages).toHaveLength(1) + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + }) + + it('returns error when streaming method is called via one-shot dispatch', async () => { + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.stream', + params: null, + handler: async () => {} + }) + ] + }) + + const response = await dispatcher.dispatch(makeRequest('test.stream')) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'method_not_supported' } + }) + }) + + it('captures handler errors in streaming dispatch', async () => { + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ + runtime: stubRuntime(), + methods: [ + defineStreamingMethod({ + name: 'test.explode', + params: null, + handler: async () => { + throw new Error('boom') + } + }) + ] + }) + + await dispatcher.dispatchStreaming(makeRequest('test.explode'), (msg) => messages.push(msg)) + + expect(messages).toHaveLength(1) + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { code: 'runtime_error' } + }) + }) +}) diff --git a/src/main/runtime/rpc/transport.ts b/src/main/runtime/rpc/transport.ts new file mode 100644 index 00000000000..8eedd20c0fb --- /dev/null +++ b/src/main/runtime/rpc/transport.ts @@ -0,0 +1,22 @@ +// Why: the transport interface decouples the RPC server from a specific +// transport mechanism (Unix socket, WebSocket, named pipe). Each transport +// owns its own connection lifecycle — the RPC server just binds message +// handling to whatever transports are registered. Individual transports +// override `onMessage` with their own richer signatures (e.g. Unix adds a +// `RpcMessageContext` with an abort signal; WebSocket adds the `ws` handle +// for auth association). Consumers hold a concrete transport type, not +// `RpcTransport`, when they need those extensions. + +// Why: per-message hook bag owned by the Unix transport. `signal` aborts +// when the underlying connection terminates so long-poll handlers can bail +// out. `startKeepalive` is opt-in per request — only long-poll dispatches +// call it, so short RPCs pay no timer overhead. See design doc §3.1. +export type RpcMessageContext = { + signal: AbortSignal + startKeepalive: () => void +} + +export type RpcTransport = { + start(): Promise + stop(): Promise +} diff --git a/src/main/runtime/rpc/unix-socket-transport.ts b/src/main/runtime/rpc/unix-socket-transport.ts new file mode 100644 index 00000000000..50c7d9822ca --- /dev/null +++ b/src/main/runtime/rpc/unix-socket-transport.ts @@ -0,0 +1,203 @@ +// Why: this is the original Unix socket / named pipe transport extracted from +// runtime-rpc.ts. It preserves the exact same behavior: newline-delimited JSON, +// 30s idle timeout, 1MB max message, 32 max connections, chmod 0o600 on Unix. +// It also owns the keepalive timer and per-connection abort signal so the +// server-side handler can cancel long-poll dispatches when the client goes +// away. See design doc §3.1. +import { createServer, type Server, type Socket } from 'net' +import { chmodSync, existsSync, rmSync } from 'fs' +import type { RpcMessageContext, RpcTransport } from './transport' + +const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024 +const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000 +const MAX_RUNTIME_RPC_CONNECTIONS = 32 +const DEFAULT_KEEPALIVE_INTERVAL_MS = 10_000 + +export type UnixSocketTransportOptions = { + endpoint: string + kind: 'unix' | 'named-pipe' + // Why: how often to write `{"_keepalive":true}\n` frames while a dispatch + // is pending. Each write resets both the server-side idle timer and, once + // the client honours them, the client-side idle timer. Tests override this + // to avoid waiting 10 s for a frame. + keepaliveIntervalMs?: number +} + +type MessageHandler = ( + msg: string, + reply: (response: string) => void, + context?: RpcMessageContext +) => void + +export class UnixSocketTransport implements RpcTransport { + private readonly endpoint: string + private readonly kind: 'unix' | 'named-pipe' + private readonly keepaliveIntervalMs: number + private server: Server | null = null + private messageHandler: MessageHandler | null = null + + constructor({ endpoint, kind, keepaliveIntervalMs }: UnixSocketTransportOptions) { + this.endpoint = endpoint + this.kind = kind + this.keepaliveIntervalMs = keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS + } + + onMessage(handler: MessageHandler): void { + this.messageHandler = handler + } + + async start(): Promise { + if (this.server) { + return + } + + if (this.kind === 'unix' && existsSync(this.endpoint)) { + rmSync(this.endpoint, { force: true }) + } + + const server = createServer((socket) => { + this.handleConnection(socket) + }) + server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(this.endpoint, () => { + server.off('error', reject) + resolve() + }) + }) + + if (this.kind === 'unix') { + chmodSync(this.endpoint, 0o600) + } + + this.server = server + } + + async stop(): Promise { + const server = this.server + this.server = null + if (!server) { + return + } + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + if (this.kind === 'unix' && existsSync(this.endpoint)) { + rmSync(this.endpoint, { force: true }) + } + } + + private handleConnection(socket: Socket): void { + let buffer = '' + let oversized = false + // Why: each in-flight dispatch registers its own AbortController here so + // `socket.on('close')` can abort them all at once. Keeping the set scoped + // to the connection (rather than a single shared controller) means + // completing one dispatch does not abort any other dispatch still running + // on the same socket — future-proofing for a persistent CLI socket that + // multiplexes sequential requests. + const inflight = new Set() + + socket.setEncoding('utf8') + socket.setNoDelay(true) + socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => { + socket.destroy() + }) + socket.on('error', () => { + socket.destroy() + }) + socket.on('close', () => { + for (const ctrl of inflight) { + ctrl.abort() + } + inflight.clear() + }) + socket.on('data', (chunk: string) => { + if (oversized) { + return + } + buffer += chunk + // Why: the Orca runtime lives in Electron main, so it must reject + // oversized local RPC frames instead of letting a local client grow an + // unbounded buffer and stall the app. + if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { + oversized = true + this.messageHandler?.('', (response) => { + socket.write(`${response}\n`) + socket.end() + }) + return + } + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1) { + const rawMessage = buffer.slice(0, newlineIndex).trim() + buffer = buffer.slice(newlineIndex + 1) + if (rawMessage) { + this.dispatchMessage(socket, rawMessage, inflight) + } + newlineIndex = buffer.indexOf('\n') + } + }) + } + + // Why: the keepalive timer is opt-in per request via `startKeepalive()`. + // Short RPCs never call it and pay no timer overhead; only long-poll + // handlers (e.g. orchestration.check --wait) arm it. See §3.1. + private dispatchMessage( + socket: Socket, + rawMessage: string, + inflight: Set + ): void { + let replied = false + let keepaliveTimer: NodeJS.Timeout | null = null + // Why: per-dispatch AbortController so completing one request does not + // abort any sibling request running on the same connection. The + // connection-level `socket.on('close')` iterates `inflight` to cancel all + // outstanding dispatches at once. + const abortController = new AbortController() + inflight.add(abortController) + + const reply = (response: string): void => { + if (replied) { + return + } + replied = true + inflight.delete(abortController) + if (keepaliveTimer) { + clearInterval(keepaliveTimer) + } + if (!socket.destroyed && socket.writable) { + socket.write(`${response}\n`) + } + } + + const startKeepalive = (): void => { + if (keepaliveTimer || replied) { + return + } + keepaliveTimer = setInterval(() => { + if (replied || socket.destroyed || !socket.writable) { + return + } + socket.write('{"_keepalive":true}\n') + }, this.keepaliveIntervalMs) + // Why: don't hold the process open solely on the keepalive interval. + if (typeof keepaliveTimer.unref === 'function') { + keepaliveTimer.unref() + } + } + + this.messageHandler?.(rawMessage, reply, { + signal: abortController.signal, + startKeepalive + }) + } +} diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts new file mode 100644 index 00000000000..8bd80ca53d8 --- /dev/null +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -0,0 +1,312 @@ +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it, afterEach } from 'vitest' +import WebSocket from 'ws' +import { WebSocketTransport } from './ws-transport' +import { loadOrCreateTlsCertificate } from '../tls-certificate' + +// Why: disable TLS verification for self-signed certs in tests. +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + +function makeTls() { + const userDataPath = mkdtempSync(join(tmpdir(), 'ws-transport-test-')) + return loadOrCreateTlsCertificate(userDataPath) +} + +function findFreePort(): number { + // Why: use port 0 to let the OS assign an available port, but the transport + // needs an explicit port. Use a high random port to minimize collisions. + return 30000 + Math.floor(Math.random() * 20000) +} + +describe('WebSocketTransport', () => { + const transports: WebSocketTransport[] = [] + + afterEach(async () => { + await Promise.all(transports.map((t) => t.stop().catch(() => {}))) + transports.length = 0 + }) + + async function createTransport( + handler?: (msg: string, reply: (response: string) => void) => void + ) { + const tls = makeTls() + const port = findFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key + }) + if (handler) { + transport.onMessage(handler) + } + transports.push(transport) + return { transport, port, tls } + } + + function connectWs(port: number): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`wss://127.0.0.1:${port}`, { + rejectUnauthorized: false + }) + ws.once('open', () => resolve(ws)) + ws.once('error', reject) + }) + } + + function sendAndReceive(ws: WebSocket, message: string): Promise { + return new Promise((resolve) => { + ws.once('message', (data) => { + resolve(typeof data === 'string' ? data : data.toString('utf-8')) + }) + ws.send(message) + }) + } + + it('starts and stops cleanly', async () => { + const { transport } = await createTransport() + + await transport.start() + await transport.stop() + }) + + it('handles request/response round-trip', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, result: { echo: true } })) + }) + + await transport.start() + + const ws = await connectWs(port) + const response = await sendAndReceive( + ws, + JSON.stringify({ id: 'req-1', method: 'test', deviceToken: 'tok' }) + ) + + expect(JSON.parse(response)).toMatchObject({ + id: 'req-1', + ok: true, + result: { echo: true } + }) + + ws.close() + }) + + it('supports multiple concurrent connections', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true })) + }) + + await transport.start() + + const clients = await Promise.all([connectWs(port), connectWs(port), connectWs(port)]) + + const responses = await Promise.all( + clients.map((ws, i) => sendAndReceive(ws, JSON.stringify({ id: `req-${i}`, method: 'test' }))) + ) + + for (let i = 0; i < 3; i++) { + expect(JSON.parse(responses[i]!)).toMatchObject({ id: `req-${i}`, ok: true }) + } + + for (const ws of clients) { + ws.close() + } + }) + + it('multiplexes multiple requests on a single connection', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, result: { method: request.method } })) + }) + + await transport.start() + + const ws = await connectWs(port) + + const r1 = sendAndReceive(ws, JSON.stringify({ id: 'a', method: 'first' })) + const resp1 = JSON.parse(await r1) + expect(resp1).toMatchObject({ id: 'a', result: { method: 'first' } }) + + const r2 = sendAndReceive(ws, JSON.stringify({ id: 'b', method: 'second' })) + const resp2 = JSON.parse(await r2) + expect(resp2).toMatchObject({ id: 'b', result: { method: 'second' } }) + + ws.close() + }) + + it('sends multiple streaming responses via reply callback', async () => { + const { transport, port } = await createTransport((msg, reply) => { + const request = JSON.parse(msg) + reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 1 } })) + reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 2 } })) + reply(JSON.stringify({ id: request.id, ok: true, result: { type: 'end' } })) + }) + + await transport.start() + + const ws = await connectWs(port) + const messages: string[] = [] + + await new Promise((resolve) => { + ws.on('message', (data) => { + messages.push(typeof data === 'string' ? data : data.toString('utf-8')) + if (messages.length === 3) { + resolve() + } + }) + ws.send(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' })) + }) + + expect(JSON.parse(messages[0]!)).toMatchObject({ streaming: true, result: { chunk: 1 } }) + expect(JSON.parse(messages[1]!)).toMatchObject({ streaming: true, result: { chunk: 2 } }) + expect(JSON.parse(messages[2]!)).toMatchObject({ result: { type: 'end' } }) + + ws.close() + }) + + it('rejects oversized messages by closing the connection', async () => { + const { transport, port } = await createTransport() + + await transport.start() + + const ws = await connectWs(port) + + // Why: ws maxPayload is 1MB — sending >1MB should trigger close. + const oversized = 'x'.repeat(1024 * 1024 + 100) + + await new Promise((resolve) => { + ws.once('close', () => resolve()) + ws.send(oversized) + }) + }) + + it('does not crash when replying to a closed connection', async () => { + let capturedReply: ((response: string) => void) | null = null + + const { transport, port } = await createTransport((_msg, reply) => { + capturedReply = reply + }) + + await transport.start() + + const ws = await connectWs(port) + ws.send(JSON.stringify({ id: 'req-1', method: 'test' })) + + // Why: wait for the handler to capture the reply function. + await new Promise((resolve) => { + const interval = setInterval(() => { + if (capturedReply) { + clearInterval(interval) + resolve() + } + }, 10) + }) + + ws.close() + + // Why: wait for the WebSocket to fully close before trying to reply. + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Should not throw — guards with readyState check. + expect(() => capturedReply!(JSON.stringify({ id: 'req-1', ok: true }))).not.toThrow() + }) + + it('is idempotent on double start', async () => { + const { transport } = await createTransport() + + await transport.start() + await transport.start() + + await transport.stop() + }) + + it('is safe to stop without starting', async () => { + const { transport } = await createTransport() + await transport.stop() + }) + + it('falls back to OS-assigned port when preferred port is in use', async () => { + const { transport: first, port } = await createTransport() + await first.start() + + // Why: second transport requests the same port, which is now occupied. + // It should silently fall back to an OS-assigned port instead of throwing. + const tls = makeTls() + const second = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key + }) + transports.push(second) + + await second.start() + + expect(second.resolvedPort).not.toBe(port) + expect(second.resolvedPort).toBeGreaterThan(0) + + const ws = await connectWs(second.resolvedPort) + ws.close() + }) + + it('reaps a half-open client that stops responding to pings', async () => { + // Why: regression cover for the half-open-socket leak that would + // strand mobile clients in the connection pool until OS TCP keepalive + // (~2 hours) reaped them. With the heartbeat, two consecutive ping + // ticks without a pong should cause terminate() to fire and free the + // slot. Verifying via the server's connection-close handler, which + // is what frees up the MAX_WS_CONNECTIONS budget in production. + const tls = makeTls() + const port = findFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port, + tlsCert: tls.cert, + tlsKey: tls.key, + heartbeatIntervalMs: 50 + }) + transport.onMessage(() => {}) + transports.push(transport) + + let serverClosed = false + transport.onConnectionClose(() => { + serverClosed = true + }) + + // Why: setClientId is what registers the ws → clientId mapping that + // onConnectionClose fires off. Hook the connection event before + // start so we can stamp every accepted ws with a token. + await transport.start() + + const ws = await connectWs(port) + // Why: pausing the underlying TCP socket halts both read (ping in) + // and write (pong out) at the kernel level, so the `ws` library's + // auto-pong can't actually be flushed back. From the server's + // perspective the client looks half-open — exactly the production + // failure mode iOS produces when it suspends a backgrounded socket. + const underlying = (ws as unknown as { _socket: { pause: () => void } })._socket + underlying.pause() + + // Why: we need a clientId on the ws so onConnectionClose actually + // fires. The transport sets it lazily via setClientId in production + // (after auth); in this test we don't run auth, so reach in. + const wss = (transport as unknown as { wss: { clients: Set<{ readyState: number }> } }).wss + for (const c of wss.clients) { + transport.setClientId(c as never, 'test-client') + } + + // Wait long enough for two heartbeat ticks (50ms each) plus slack. + const start = Date.now() + while (!serverClosed && Date.now() - start < 2_000) { + await new Promise((r) => setTimeout(r, 25)) + } + + expect(serverClosed).toBe(true) + expect(wss.clients.size).toBe(0) + }, 5_000) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts new file mode 100644 index 00000000000..5a97642a30c --- /dev/null +++ b/src/main/runtime/rpc/ws-transport.ts @@ -0,0 +1,288 @@ +// Why: the WebSocket transport enables mobile clients to connect to the Orca +// runtime over the local network. When TLS cert/key are provided it uses wss:// +// to prevent passive sniffing; otherwise it falls back to plain ws://. Per-device +// tokens (validated by the message handler in OrcaRuntimeRpcServer) provide auth +// regardless of transport encryption. +import { createServer as createHttpsServer, type Server as HttpsServer } from 'https' +import { createServer as createHttpServer, type Server as HttpServer } from 'http' +import { WebSocketServer, type WebSocket } from 'ws' +import type { RpcTransport } from './transport' + +const MAX_WS_MESSAGE_BYTES = 1024 * 1024 +const MAX_WS_CONNECTIONS = 32 + +// Why: mobile clients (iOS/Android) regularly background-suspend their +// sockets without the OS sending a TCP FIN/RST, leaving the server with +// half-open WebSockets that count toward MAX_WS_CONNECTIONS. Without this +// heartbeat the only thing that ever reaps them is the OS's TCP keepalive +// (default macOS: ~2 hours idle + 11 min of probes), which is the +// "connection randomly turns green again after a long delay" symptom. +// Pinging every 15s and terminating any client that hasn't pong'd by the +// next tick collapses that worst case to ~30s. RN/browser WebSocket +// runtimes auto-respond to server pings with pongs at the protocol layer, +// so this works for any client that speaks RFC 6455. +const HEARTBEAT_INTERVAL_MS = 15_000 + +export type WebSocketTransportOptions = { + host: string + port: number + tlsCert?: string + tlsKey?: string + // Why: test-only override. Production uses HEARTBEAT_INTERVAL_MS. + heartbeatIntervalMs?: number +} + +export class WebSocketTransport implements RpcTransport { + private readonly host: string + private readonly port: number + private readonly tlsCert: string | undefined + private readonly tlsKey: string | undefined + private readonly heartbeatIntervalMs: number + private httpServer: HttpsServer | HttpServer | null = null + private wss: WebSocketServer | null = null + private heartbeatTimer: ReturnType | null = null + // Why: tracks whether each socket has pong'd since the last heartbeat + // sweep. A socket missing from the set when the next sweep fires is + // assumed dead and terminated. + private wsAlive = new WeakSet() + private messageHandler: + | ((msg: string, reply: (response: string) => void, ws: WebSocket) => void) + | null = null + private connectionCloseHandler: + | ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void) + | null = null + // Why: maps each WebSocket to the clientId (deviceToken) that authenticated it, + // so ws.on('close') can notify the runtime which mobile client disconnected. + private wsClientIds = new Map() + + constructor({ host, port, tlsCert, tlsKey, heartbeatIntervalMs }: WebSocketTransportOptions) { + this.host = host + this.port = port + this.tlsCert = tlsCert + this.tlsKey = tlsKey + this.heartbeatIntervalMs = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS + } + + onMessage( + handler: (msg: string, reply: (response: string) => void, ws: WebSocket) => void + ): void { + this.messageHandler = handler + } + + // Why: handlers receive the closing `ws` so per-connection state can be + // targeted exactly (one paired device may hold multiple concurrent sockets, + // e.g. host screen + accounts screen). `hasOtherConnections` tells the + // runtime whether other sockets for the same deviceToken are still alive, + // so client-scoped teardown (mobile-fit overrides, etc.) only fires on the + // last disconnect. + onConnectionClose( + handler: (clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void + ): void { + this.connectionCloseHandler = handler + } + + setClientId(ws: WebSocket, clientId: string): void { + this.wsClientIds.set(ws, clientId) + } + + // Why: when port 0 is passed the OS assigns a random available port. The + // runtime metadata and mobile QR code need the real port, so callers read + // it here after start() resolves. + get resolvedPort(): number { + const addr = this.httpServer?.address() + if (addr && typeof addr === 'object') { + return addr.port + } + return this.port + } + + async start(): Promise { + if (this.wss) { + return + } + + // Why: when the preferred port is occupied (e.g. another Orca instance is + // already running), fall back to an OS-assigned port so mobile pairing + // still works. The QR code reads resolvedPort after start, so it will + // advertise the correct port regardless. + let port = this.port + try { + await this.tryListen(port) + } catch (error: unknown) { + if (isEAddressInUse(error) && port !== 0) { + console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`) + port = 0 + await this.tryListen(port) + } else { + throw error + } + } + } + + private createHttpServer(): HttpServer | HttpsServer { + return this.tlsCert && this.tlsKey + ? createHttpsServer({ cert: this.tlsCert, key: this.tlsKey }) + : createHttpServer() + } + + // Why: the WebSocketServer is attached only after listen succeeds. If we + // attached it before, the WSS would re-emit the EADDRINUSE error from the + // httpServer as an uncatchable exception, preventing the fallback from working. + private async tryListen(port: number): Promise { + const httpServer = this.createHttpServer() + + await new Promise((resolve, reject) => { + httpServer.once('error', reject) + httpServer.listen(port, this.host, () => { + httpServer.off('error', reject) + resolve() + }) + }) + + const wss = new WebSocketServer({ + server: httpServer, + maxPayload: MAX_WS_MESSAGE_BYTES + }) + + wss.on('connection', (ws) => { + if (wss.clients.size > MAX_WS_CONNECTIONS) { + ws.close(1013, 'Maximum connections reached') + return + } + this.handleConnection(ws) + }) + + this.httpServer = httpServer + this.wss = wss + this.startHeartbeat() + } + + // Why: ping every live socket on a fixed cadence and terminate any that + // didn't pong since the previous tick. This is the only thing that + // reliably reaps half-open mobile sockets stranded by background + // suspension without a TCP FIN. See HEARTBEAT_INTERVAL_MS comment. + private startHeartbeat(): void { + if (this.heartbeatTimer) { + return + } + this.heartbeatTimer = setInterval(() => { + const wss = this.wss + if (!wss) { + return + } + for (const ws of wss.clients) { + if (!this.wsAlive.has(ws)) { + // Why: terminate() (vs close()) skips the close handshake and + // immediately fires the 'close' event, freeing the slot. close() + // on an already-dead socket can hang for the OS-level TCP timeout. + ws.terminate() + continue + } + this.wsAlive.delete(ws) + try { + ws.ping() + } catch { + // Why: ping() can throw on a socket that's mid-tear-down; the + // close handler will run regardless, so swallow the throw. + } + } + }, this.heartbeatIntervalMs) + if (typeof this.heartbeatTimer.unref === 'function') { + this.heartbeatTimer.unref() + } + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + } + + async stop(): Promise { + const wss = this.wss + const httpServer = this.httpServer + this.wss = null + this.httpServer = null + this.stopHeartbeat() + + if (wss) { + for (const client of wss.clients) { + client.close(1001, 'Server shutting down') + } + wss.close() + } + + if (httpServer) { + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } + } + + // Why: WebSocket connections are long-lived (unlike Unix socket which is + // one-per-request). Multiple requests can be multiplexed on the same + // connection via the RPC `id` field. The transport delegates all auth + // and dispatch logic to the message handler set by OrcaRuntimeRpcServer. + private handleConnection(ws: WebSocket): void { + // Why: seed alive=true so the first heartbeat tick after connect doesn't + // treat a fresh socket as dead. Subsequent pongs (or any inbound traffic) + // re-arm it. + this.wsAlive.add(ws) + + ws.on('pong', () => { + this.wsAlive.add(ws) + }) + + ws.on('message', (data) => { + // Why: any inbound traffic counts as proof of life, not just pongs. + // RN's WebSocket runtime auto-pongs server pings transparently, but + // app-level frames also count toward liveness so an actively-talking + // client doesn't get terminated mid-request. + this.wsAlive.add(ws) + const msg = typeof data === 'string' ? data : data.toString('utf-8') + this.messageHandler?.( + msg, + (response) => { + // Why: mobile clients disconnect frequently (backgrounding, network + // switch, phone locked). Guard writes to avoid errors on dead sockets. + if (ws.readyState === ws.OPEN) { + ws.send(response) + } + }, + ws + ) + }) + + // Why: mobile clients disconnect when the phone locks, loses wifi, or + // backgrounds the app. The runtime must clean up connection-scoped state + // (e.g., mobile-fit overrides) to prevent orphaned phone-fit on desktop. + ws.on('close', () => { + const clientId = this.wsClientIds.get(ws) + this.wsClientIds.delete(ws) + if (clientId) { + // Why: a paired device may have multiple concurrent sockets open + // (e.g. one per app screen). Per-client teardown must only fire when + // the last socket for this token closes — otherwise closing the + // accounts-screen socket would clobber the host-screen socket's + // state and strand it in a non-functional state until re-paired. + const hasOtherConnections = Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) + } + }) + + ws.on('error', () => { + ws.close() + }) + } +} + +function isEAddressInUse(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EADDRINUSE' +} diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts index 01774eca88f..4e93e909dd6 100644 --- a/src/main/runtime/runtime-metadata.test.ts +++ b/src/main/runtime/runtime-metadata.test.ts @@ -26,10 +26,12 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) @@ -37,10 +39,12 @@ describe('runtime metadata', () => { expect(readRuntimeMetadata(userDataPath)).toEqual({ runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) @@ -53,7 +57,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: null, + transports: [], authToken: null, startedAt: 100 }) @@ -71,7 +75,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_owner', pid: 42, - transport: null, + transports: [], authToken: null, startedAt: 100 }) @@ -87,7 +91,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_replacement', pid: 999, - transport: null, + transports: [], authToken: null, startedAt: 200 }) @@ -109,7 +113,7 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_replacement', pid: 42, - transport: null, + transports: [], authToken: null, startedAt: 200 }) @@ -140,10 +144,12 @@ describe('runtime metadata', () => { writeRuntimeMetadata(userDataPath, { runtimeId: 'rt_123', pid: 42, - transport: { - kind: 'unix', - endpoint: '/tmp/orca.sock' - }, + transports: [ + { + kind: 'unix', + endpoint: '/tmp/orca.sock' + } + ], authToken: 'secret', startedAt: 100 }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 58fff491f44..d07fc93b01b 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -2,9 +2,11 @@ import { existsSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { createConnection } from 'net' +import { createConnection, type Socket } from 'net' import { describe, expect, it, vi } from 'vitest' +import Database from 'better-sqlite3' import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' @@ -46,6 +48,62 @@ async function sendRequest( }) } +// Why: long-poll keepalive tests need every frame, not just the first, because +// we need to count `_keepalive` frames before the terminal success/failure. +// Also exposes the socket so tests can close it mid-wait to exercise the +// long-poll counter decrement path. +type FramedSession = { + socket: Socket + frames: Record[] + done: Promise +} + +function openFramedSession(endpoint: string, request: Record): FramedSession { + const frames: Record[] = [] + const socket = createConnection(endpoint) + let buffer = '' + socket.setEncoding('utf8') + const done = new Promise((resolve, reject) => { + socket.once('error', (err) => { + // Why: ECONNRESET is expected when we deliberately destroy the socket + // mid-wait to probe the counter decrement; surface other errors. + if ((err as NodeJS.ErrnoException).code === 'ECONNRESET') { + resolve() + return + } + reject(err) + }) + socket.on('close', () => resolve()) + socket.on('data', (chunk: string) => { + buffer += chunk + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1) { + const raw = buffer.slice(0, newlineIndex).trim() + buffer = buffer.slice(newlineIndex + 1) + if (raw) { + const frame = JSON.parse(raw) as Record + frames.push(frame) + // Why: the server leaves the socket open after writing the terminal + // frame (short RPCs expect the client to close); close the client + // side so `done` resolves once we've captured the response. + if (frame._keepalive !== true) { + socket.end() + } + } + newlineIndex = buffer.indexOf('\n') + } + }) + socket.on('connect', () => { + socket.write(`${JSON.stringify(request)}\n`) + }) + }) + return { socket, frames, done } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + describe('OrcaRuntimeRpcServer', () => { const makeStore = (overrides?: { isUnread?: boolean }) => ({ getRepo: (id: string) => @@ -109,8 +167,8 @@ describe('OrcaRuntimeRpcServer', () => { const metadata = readRuntimeMetadata(userDataPath) expect(metadata?.runtimeId).toBe(runtime.getRuntimeId()) expect(metadata?.authToken).toBeTruthy() - expect(metadata?.transport?.endpoint).toBeTruthy() - expect(metadata?.transport).toEqual(server['transport']) + expect(metadata?.transports?.[0]?.endpoint).toBeTruthy() + expect(metadata?.transports).toEqual(server['transports']) await server.stop() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ @@ -157,8 +215,8 @@ describe('OrcaRuntimeRpcServer', () => { await expect(server.start()).rejects.toThrow('write failed') expect(readRuntimeMetadata(userDataPath)).toBeNull() expect(existsSync(endpoint)).toBe(false) - expect(server['transport']).toBeNull() - expect(server['server']).toBeNull() + expect(server['transports']).toEqual([]) + expect(server['activeTransports']).toEqual([]) writeMetadataSpy.mockRestore() }) @@ -171,7 +229,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_1', authToken: metadata!.authToken, method: 'status.get' @@ -197,7 +255,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_1', authToken: 'wrong', method: 'status.get' @@ -222,7 +280,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { authToken: metadata!.authToken, method: 'status.get' }) @@ -278,7 +336,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const listResponse = await sendRequest(metadata!.transport!.endpoint, { + const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_list', authToken: metadata!.authToken, method: 'terminal.list', @@ -302,7 +360,7 @@ describe('OrcaRuntimeRpcServer', () => { ).handle expect(handle).toBeTruthy() - const showResponse = await sendRequest(metadata!.transport!.endpoint, { + const showResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_show', authToken: metadata!.authToken, method: 'terminal.show', @@ -315,7 +373,7 @@ describe('OrcaRuntimeRpcServer', () => { ok: true }) - const readResponse = await sendRequest(metadata!.transport!.endpoint, { + const readResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_read', authToken: metadata!.authToken, method: 'terminal.read', @@ -328,7 +386,7 @@ describe('OrcaRuntimeRpcServer', () => { ok: true }) - const sendResponse = await sendRequest(metadata!.transport!.endpoint, { + const sendResponse = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_send', authToken: metadata!.authToken, method: 'terminal.send', @@ -344,7 +402,7 @@ describe('OrcaRuntimeRpcServer', () => { }) expect(writes).toEqual(['continue', '\r']) - const waitPromise = sendRequest(metadata!.transport!.endpoint, { + const waitPromise = sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_wait', authToken: metadata!.authToken, method: 'terminal.wait', @@ -404,7 +462,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_ps', authToken: metadata!.authToken, method: 'worktree.ps' @@ -445,7 +503,7 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() const metadata = readRuntimeMetadata(userDataPath) - const response = await sendRequest(metadata!.transport!.endpoint, { + const response = await sendRequest(metadata!.transports[0]!.endpoint, { id: 'req_worktrees', authToken: metadata!.authToken, method: 'worktree.list', @@ -475,7 +533,7 @@ describe('OrcaRuntimeRpcServer', () => { const metadata = readRuntimeMetadata(userDataPath) const response = await new Promise>((resolve, reject) => { - const socket = createConnection(metadata!.transport!.endpoint) + const socket = createConnection(metadata!.transports[0]!.endpoint) let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) @@ -502,4 +560,308 @@ describe('OrcaRuntimeRpcServer', () => { await server.stop() }) + + // Why: §6 tests for the transport keepalive + long-poll counter path in §3.1. + // Exercise the real socket (not a mock) so we catch buffer/flush regressions + // that a unit-level test would miss. + describe('long-poll transport (§3.1)', () => { + it('emits keepalive frames while a check --wait handler blocks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + // Why: 50ms keepalive lets us collect ≥3 frames within a 300ms wait + // window without slowing the suite. + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 50 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_wait', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { + terminal: 'term_nobody', + wait: true, + timeoutMs: 300 + } + }) + await session.done + + const keepalives = session.frames.filter((f) => f._keepalive === true) + const terminals = session.frames.filter((f) => f.ok !== undefined) + expect(terminals).toHaveLength(1) + expect(terminals[0]).toMatchObject({ id: 'req_wait', ok: true }) + // Why: 300ms wait with 50ms keepalive → expect roughly 5 keepalives; + // assert ≥3 to tolerate scheduler jitter without flaking. + expect(keepalives.length).toBeGreaterThanOrEqual(3) + } finally { + db.close() + await server.stop() + } + }) + + it('releases long-poll slot when client closes mid-wait', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 1000, + longPollCap: 2 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const endpoint = metadata!.transports[0]!.endpoint + + // Fill the cap with two long waits (10s each — we'll kill them). + const a = openFramedSession(endpoint, { + id: 'req_a', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_a', wait: true, timeoutMs: 10_000 } + }) + const b = openFramedSession(endpoint, { + id: 'req_b', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_b', wait: true, timeoutMs: 10_000 } + }) + // Let the two waits land in the handler and increment the counter. + await sleep(100) + expect(server['activeLongPolls']).toBe(2) + + // Kill one client mid-wait; counter must drop to 1. + a.socket.destroy() + await a.done + // Give Node one tick to fire the close event on the server socket. + await sleep(50) + expect(server['activeLongPolls']).toBe(1) + + // The freed slot must admit a new long-poll immediately. + const c = openFramedSession(endpoint, { + id: 'req_c', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_c', wait: true, timeoutMs: 100 } + }) + await c.done + const cTerminal = c.frames.find((f) => f.ok !== undefined) + expect(cTerminal).toMatchObject({ ok: true, id: 'req_c' }) + + b.socket.destroy() + await b.done + } finally { + db.close() + await server.stop() + } + }) + + it('responds runtime_busy once the long-poll cap is saturated', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 1000, + longPollCap: 1 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const endpoint = metadata!.transports[0]!.endpoint + + const a = openFramedSession(endpoint, { + id: 'req_a', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_a', wait: true, timeoutMs: 5_000 } + }) + await sleep(100) + expect(server['activeLongPolls']).toBe(1) + + // Second long-poll overflows the cap → runtime_busy. + const overflow = await sendRequest(endpoint, { + id: 'req_overflow', + authToken: metadata!.authToken, + method: 'orchestration.check', + params: { terminal: 'term_b', wait: true, timeoutMs: 5_000 } + }) + expect(overflow).toMatchObject({ + id: 'req_overflow', + ok: false, + error: { code: 'runtime_busy' } + }) + // The failing request must not have counted against the cap. + expect(server['activeLongPolls']).toBe(1) + + // Short RPCs still succeed even when the long-poll cap is full. + const short = await sendRequest(endpoint, { + id: 'req_short', + authToken: metadata!.authToken, + method: 'status.get' + }) + expect(short).toMatchObject({ id: 'req_short', ok: true }) + + a.socket.destroy() + await a.done + } finally { + db.close() + await server.stop() + } + }) + + it('does not emit keepalive frames for short RPCs', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + // Why: a 10ms interval means any frame in the first ~100ms of a short + // RPC would show up; `status.get` returns in <10ms so no keepalive + // should ever fire. Locks in the "keepalive is long-poll-only" invariant + // so a future refactor can't silently re-broaden the timer. + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 10 + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_short', + authToken: metadata!.authToken, + method: 'status.get' + }) + await session.done + + const keepalives = session.frames.filter((f) => f._keepalive === true) + const terminals = session.frames.filter((f) => f.ok !== undefined) + expect(terminals).toHaveLength(1) + expect(terminals[0]).toMatchObject({ id: 'req_short', ok: true }) + expect(keepalives).toHaveLength(0) + } finally { + await server.stop() + } + }) + + it('returns an internal_error envelope when the dispatcher throws', async () => { + // Why: handlers are designed to return error envelopes, never to throw, + // but a bug somewhere in the RPC stack (e.g. JSON.stringify choking on + // a response with circular refs) must still produce a terminal frame. + // Without the `.catch` on handleMessage's promise, a throw would leave + // the client hanging until the 30s idle timer and leak the dispatch's + // AbortController in the transport's in-flight set. + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) + await server.start() + + // Force the dispatcher to throw a non-envelope error. + const originalDispatch = server['dispatcher'].dispatch.bind(server['dispatcher']) + server['dispatcher'].dispatch = vi.fn().mockRejectedValue(new Error('boom')) + + try { + const metadata = readRuntimeMetadata(userDataPath) + const response = await sendRequest(metadata!.transports[0]!.endpoint, { + id: 'req_throw', + authToken: metadata!.authToken, + method: 'status.get' + }) + expect(response).toMatchObject({ + id: 'req_throw', + ok: false, + error: { code: 'internal_error', message: 'boom' } + }) + } finally { + server['dispatcher'].dispatch = originalDispatch + await server.stop() + } + }) + }) + + // Why: §6 test for the idempotent + hard-fail schema migration. A broken + // migration must crash startup loudly rather than serve traffic against a + // schema missing the delivered_at column. + describe('orchestration DB migration (§3.2)', () => { + it('is idempotent when delivered_at already exists', () => { + // First open creates the column; second open should be a no-op. + const db1 = new OrchestrationDb(':memory:') + db1.close() + // File path reuse is meaningless with :memory:, so use a tmp file. + const tmpPath = join(mkdtempSync(join(tmpdir(), 'orca-orch-mig-')), 'orch.sqlite') + const a = new OrchestrationDb(tmpPath) + a.close() + // Second construction must not throw "duplicate column name". + expect(() => { + const b = new OrchestrationDb(tmpPath) + b.close() + }).not.toThrow() + }) + + it('hard-fails startup when the migration cannot be applied', () => { + // Simulate a migration error by monkey-patching better-sqlite3's exec. + // If ALTER TABLE throws for any reason (e.g. disk full, permissions), + // the constructor must propagate — not swallow and serve half-broken. + // + // Why the pre-seeded v2 DB: after the schema bundle, fresh DBs are + // initialized directly at v3 via createTables() (which already includes + // `delivered_at`), so the v2 → v3 ALTER is a no-op for new installs. + // To exercise the hard-fail path we need a DB that actually has work + // to migrate — a v2-shape file without the delivered_at column — so + // the guarded ALTER runs and the stub can fire. + const tmpPath = join(mkdtempSync(join(tmpdir(), 'orca-orch-mig-')), 'orch.sqlite') + const seed = new Database(tmpPath) + seed.exec(` + CREATE TABLE messages ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate', 'heartbeat' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `) + seed.pragma('user_version = 2') + seed.close() + + const realPrototype = Database.prototype as unknown as { + exec: (sql: string) => unknown + } + const originalExec = realPrototype.exec + realPrototype.exec = function (sql: string) { + if (sql.includes('ALTER TABLE messages ADD COLUMN delivered_at')) { + throw new Error('simulated migration failure') + } + return originalExec.call(this, sql) + } + try { + expect(() => new OrchestrationDb(tmpPath)).toThrow('simulated migration failure') + } finally { + realPrototype.exec = originalExec + } + }) + }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8ebb8778736..c1f946e6990 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1,11 +1,11 @@ +/* eslint-disable max-lines -- Why: this file is the single security boundary for the bundled CLI — transport setup, auth-token enforcement, admission control, keepalive framing, and orphan-socket sweeping all co-locate deliberately so a reviewer can audit the boundary in one sitting. Splitting this across files would scatter the invariants without reducing complexity. */ // Why: this is the single security boundary for the bundled CLI. It owns -// transport setup (unix socket / named pipe), auth-token enforcement, and -// bootstrap-metadata publication so a running runtime is always discoverable -// via exactly one on-disk file. Method handling lives in `rpc/` so this file -// stays easy to audit in one sitting. +// auth-token enforcement, bootstrap-metadata publication, and transport +// orchestration so a running runtime is always discoverable via exactly +// one on-disk file. Method handling lives in `rpc/` and transport specifics +// live in `rpc/unix-socket-transport.ts` and `rpc/ws-transport.ts`. import { randomBytes } from 'crypto' -import { createServer, type Server, type Socket } from 'net' -import { chmodSync, existsSync, readdirSync, rmSync } from 'fs' +import { readdirSync, rmSync } from 'fs' import { join } from 'path' import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' import type { OrcaRuntimeService } from './orca-runtime' @@ -13,17 +13,61 @@ import { writeRuntimeMetadata } from './runtime-metadata' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest, RpcResponse } from './rpc/core' import { errorResponse } from './rpc/errors' +import type { RpcMessageContext, RpcTransport } from './rpc/transport' +import { UnixSocketTransport } from './rpc/unix-socket-transport' +import { WebSocketTransport } from './rpc/ws-transport' +import type { WebSocket } from 'ws' +import { DeviceRegistry } from './device-registry' +import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' +import { E2EEChannel } from './rpc/e2ee-channel' + +const DEFAULT_WS_PORT = 6768 type OrcaRuntimeRpcServerOptions = { runtime: OrcaRuntimeService userDataPath: string pid?: number platform?: NodeJS.Platform + enableWebSocket?: boolean + wsPort?: number + // Why: test-only overrides for the two time-bound constants below. + // Production callers must not pass these — defaults are set by the design + // doc (§3.1) and changing them in production would weaken the admission + // fence or flood the socket with keepalive frames. + keepaliveIntervalMs?: number + longPollCap?: number } -const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024 -const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000 -const MAX_RUNTIME_RPC_CONNECTIONS = 32 +// Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}` +// frame every 10 s until the handler resolves. Each write resets both the +// server's own socket idle timer (30 s) and — once §3.1 ships on the client — +// the client's idle timer, because any byte counts as socket activity. This +// is the transport-layer fix for feedback #1: long-poll RPCs (i.e. +// orchestration.check --wait) can now run past the 30 s/60 s idle caps +// without either end tearing the socket down. See design doc §3.1. +const KEEPALIVE_INTERVAL_MS = 10_000 + +// Why: long-poll slot cap. With keepalives a `check --wait --timeout-ms +// 600000` can hold a connection for up to 10 minutes; unbounded that would +// saturate MAX_RUNTIME_RPC_CONNECTIONS (32) with 32 waiting coordinators +// and lock out normal short RPCs. Capping at half the connection budget +// leaves the other half for short traffic. On overflow the server responds +// immediately with `runtime_busy` (CLI exit 75) — fail fast, not silent +// queuing. See design doc §3.1 + §7 risk #2. +const LONG_POLL_CAP = 16 + +// Why: a long-poll request is one whose handler blocks for an unbounded +// amount of time waiting for an external event (today, only +// `orchestration.check` with `wait === true`). This function is the single +// place that classifies it — the long-poll counter, abort wiring, and +// runtime_busy admission check all share this decision. See §3.1. +function isLongPollRequest(request: RpcRequest): boolean { + if (request.method !== 'orchestration.check') { + return false + } + const params = request.params as { wait?: unknown } | undefined + return params?.wait === true +} export class OrcaRuntimeRpcServer { private readonly runtime: OrcaRuntimeService @@ -31,25 +75,72 @@ export class OrcaRuntimeRpcServer { private readonly userDataPath: string private readonly pid: number private readonly platform: NodeJS.Platform + private readonly enableWebSocket: boolean + private readonly wsPort: number private readonly authToken = randomBytes(24).toString('hex') - private server: Server | null = null - private transport: RuntimeTransportMetadata | null = null + private readonly keepaliveIntervalMs: number + private readonly longPollCap: number + private deviceRegistry: DeviceRegistry | null = null + private e2eeKeypair: E2EEKeypair | null = null + private tlsFingerprint: string | null = null + private activeTransports: RpcTransport[] = [] + private transports: RuntimeTransportMetadata[] = [] + // Why: each WebSocket connection has its own E2EE channel that manages the + // handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance. + private e2eeChannels = new Map() + // Why: stable per-WebSocket id used as the cleanup key for streaming + // subscriptions, so the server can reap a closing socket's subscriptions + // without affecting other live sockets that share the same deviceToken. + private wsConnectionIds = new Map() + // Why: separate from Node's server.maxConnections because we need to count + // only long-running dispatches, not every in-flight short RPC. See §3.1 + + // §7 risk #2. + private activeLongPolls = 0 constructor({ runtime, userDataPath, pid = process.pid, - platform = process.platform + platform = process.platform, + enableWebSocket = false, + wsPort = DEFAULT_WS_PORT, + keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, + longPollCap = LONG_POLL_CAP }: OrcaRuntimeRpcServerOptions) { this.runtime = runtime this.dispatcher = new RpcDispatcher({ runtime }) this.userDataPath = userDataPath this.pid = pid this.platform = platform + this.enableWebSocket = enableWebSocket + this.wsPort = wsPort + this.keepaliveIntervalMs = keepaliveIntervalMs + this.longPollCap = longPollCap + } + + getDeviceRegistry(): DeviceRegistry | null { + return this.deviceRegistry + } + + getTlsFingerprint(): string | null { + return this.tlsFingerprint + } + + getE2EEPublicKey(): string | null { + return this.e2eeKeypair?.publicKeyB64 ?? null + } + + getE2EEKeypair(): E2EEKeypair | null { + return this.e2eeKeypair + } + + getWebSocketEndpoint(): string | null { + const ws = this.transports.find((t) => t.kind === 'websocket') + return ws?.endpoint ?? null } async start(): Promise { - if (this.server) { + if (this.activeTransports.length > 0) { return } @@ -63,82 +154,177 @@ export class OrcaRuntimeRpcServer { sweepOrphanedRuntimeSockets(this.userDataPath, this.pid) } - const transport = createRuntimeTransportMetadata( + const transportMeta = createRuntimeTransportMetadata( this.userDataPath, this.pid, this.platform, this.runtime.getRuntimeId() ) - if (transport.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } - const server = createServer((socket) => { - this.handleConnection(socket) + const socketTransport = new UnixSocketTransport({ + endpoint: transportMeta.endpoint, + kind: transportMeta.kind as 'unix' | 'named-pipe', + keepaliveIntervalMs: this.keepaliveIntervalMs }) - server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(transport.endpoint, () => { - server.off('error', reject) - resolve() - }) + // Why: Unix socket transport uses the shared runtime auth token. This is + // the existing security model for CLI connections — the token lives in a + // 0o600-permissioned file on disk. + // Why: the `.catch` guarantees `reply()` always fires even if + // `handleMessage` (or `JSON.stringify` on a pathological response) throws. + // Without it, a throw would leave the client waiting for a terminal frame + // that never arrives AND leak the dispatch's AbortController in the + // transport's in-flight set until the 30 s socket idle timer closes the + // connection. + socketTransport.onMessage((msg, reply, context) => { + void this.handleMessage(msg, context) + .then((response) => { + reply(JSON.stringify(response)) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + // Why: best-effort id recovery so the client can correlate the + // error frame to its pending request. A malformed message would + // have been caught by handleMessage and returned an envelope + // instead of throwing, so in practice the id is always present. + let id = 'unknown' + try { + const parsed = JSON.parse(msg) as { id?: unknown } + if (typeof parsed.id === 'string' && parsed.id.length > 0) { + id = parsed.id + } + } catch { + // ignore — fall through with id='unknown' + } + reply(JSON.stringify(this.buildError(id, 'internal_error', message))) + }) }) - if (transport.kind === 'unix') { - chmodSync(transport.endpoint, 0o600) + + await socketTransport.start() + + const activeTransports: RpcTransport[] = [socketTransport] + const transportsMeta: RuntimeTransportMetadata[] = [transportMeta] + + // Why: WebSocket transport is opt-in and starts alongside the Unix socket. + // It uses per-device tokens and E2EE (application-layer encryption via + // tweetnacl) rather than TLS, since React Native can't pin self-signed certs. + if (this.enableWebSocket) { + try { + this.deviceRegistry = new DeviceRegistry(this.userDataPath) + this.e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) + + const wsTransport = new WebSocketTransport({ + host: '0.0.0.0', + port: this.wsPort + }) + + // Why: each WebSocket connection gets an E2EE channel that handles the + // handshake before any RPC messages are processed. The channel decrypts + // inbound messages and encrypts outbound replies transparently. + wsTransport.onMessage((msg, _reply, ws) => { + let channel = this.e2eeChannels.get(ws) + if (!channel) { + // Why: stable per-ws id used as the cleanup-index key for + // streaming subscriptions, so the server can reap them exactly + // when this socket closes (without affecting other live sockets + // that share the same deviceToken). + this.wsConnectionIds.set(ws, randomBytes(8).toString('hex')) + channel = new E2EEChannel(ws, { + serverSecretKey: this.e2eeKeypair!.secretKey, + validateToken: (token) => this.deviceRegistry?.validateToken(token) != null, + onReady: (ch) => { + if (ch.deviceToken) { + wsTransport.setClientId(ws, ch.deviceToken) + // Why: mark the device as actually connected so it appears + // in the "Paired Devices" list. Devices that were only + // generated as QR codes but never scanned stay hidden. + const device = this.deviceRegistry?.validateToken(ch.deviceToken) + if (device) { + this.deviceRegistry?.updateLastSeen(device.deviceId) + } + } + }, + onError: (code, reason) => { + this.e2eeChannels.get(ws)?.destroy() + this.e2eeChannels.delete(ws) + ws.close(code, reason) + } + }) + channel.onMessage((plaintext, encryptedReply) => { + void this.handleWebSocketMessage(plaintext, encryptedReply, wsTransport, ws) + }) + this.e2eeChannels.set(ws, channel) + } + channel.handleRawMessage(msg) + }) + + // Why: when a mobile client disconnects, the runtime must clean up + // connection-scoped state like mobile-fit overrides and the E2EE + // channel to prevent orphaned state. A single paired device can hold + // multiple concurrent sockets (host screen + accounts screen, etc.), + // so destroy the channel for THIS exact ws and skip the per-client + // teardown when other sockets for the same token are still alive. + wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => { + // Why: sweep streaming subscriptions for THIS ws regardless of + // hasOtherConnections, so per-ws listeners (notifications, + // accounts, terminal) don't leak across reconnects. This is + // independent of the deviceToken-scoped onClientDisconnected. + const connectionId = this.wsConnectionIds.get(ws) + if (connectionId) { + this.runtime.cleanupSubscriptionsForConnection(connectionId) + this.wsConnectionIds.delete(ws) + } + const channel = this.e2eeChannels.get(ws) + if (channel) { + channel.destroy() + this.e2eeChannels.delete(ws) + } + if (!hasOtherConnections) { + this.runtime.onClientDisconnected(clientId) + } + }) + + await wsTransport.start() + activeTransports.push(wsTransport) + transportsMeta.push({ + kind: 'websocket', + endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}` + }) + } catch (error) { + // Why: WebSocket transport is supplementary — the runtime must still + // function if it fails to start (e.g., port in use). Log and continue + // with Unix socket only. + console.error('[runtime] Failed to start WebSocket transport:', error) + } } // Why: publish the transport into in-memory state before writing metadata // so the bootstrap file always contains the real endpoint/token pair. The // CLI only discovers the runtime through that file. - this.server = server - this.transport = transport + this.activeTransports = activeTransports + this.transports = transportsMeta try { this.writeMetadata() } catch (error) { // Why: a runtime that cannot publish bootstrap metadata is invisible to - // the `orca` CLI. Close the socket immediately instead of leaving behind - // a live but undiscoverable control plane. - this.server = null - this.transport = null - await new Promise((resolve, reject) => { - server.close((closeError) => { - if (closeError) { - reject(closeError) - return - } - resolve() - }) - }).catch(() => {}) - if (transport.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } + // the `orca` CLI. Close all transports immediately instead of leaving + // behind a live but undiscoverable control plane. + this.activeTransports = [] + this.transports = [] + await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) throw error } } async stop(): Promise { - const server = this.server - const transport = this.transport - this.server = null - this.transport = null - if (!server) { + const transports = this.activeTransports + this.activeTransports = [] + this.transports = [] + if (transports.length === 0) { return } - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) - if (transport?.kind === 'unix' && existsSync(transport.endpoint)) { - rmSync(transport.endpoint, { force: true }) - } + await Promise.all(transports.map((t) => t.stop())) // Why: we intentionally leave the last metadata file behind instead of // deleting it on shutdown. Shared userData paths can briefly host multiple // Orca processes during restarts, updates, or development, and stale @@ -146,65 +332,126 @@ export class OrcaRuntimeRpcServer { // bootstrap file. } - private handleConnection(socket: Socket): void { - let buffer = '' + // Why: Unix socket messages use one-shot dispatch (single response per + // request) and the shared runtime auth token from the 0o600 metadata file. + // The transport layer owns socket lifecycle, keepalive writes, and the + // per-connection abort signal — this method just parses, auths, and + // dispatches. See design doc §3.1. + private async handleMessage( + rawMessage: string, + context?: RpcMessageContext + ): Promise { + // Why: empty messages are sent by the Unix socket transport layer when a + // client exceeds the max message size. The transport closes the connection + // after this response. + if (!rawMessage) { + return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size') + } - socket.setEncoding('utf8') - socket.setNoDelay(true) - socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => { - socket.destroy() - }) - socket.on('error', () => { - socket.destroy() - }) - socket.on('data', (chunk: string) => { - buffer += chunk - // Why: the Orca runtime lives in Electron main, so it must reject - // oversized local RPC frames instead of letting a local client grow an - // unbounded buffer and stall the app. - if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { - socket.write( - `${JSON.stringify(this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n` - ) - socket.end() - return + const parsed = this.parseAndAuth(rawMessage) + if ('error' in parsed) { + return parsed.error + } + const request = parsed.request + + // Why: long-poll admission fence. Short RPCs bypass the counter entirely + // — it only guards handlers that can block for minutes. See §7 risk #2. + const longPoll = isLongPollRequest(request) + if (longPoll && this.activeLongPolls >= this.longPollCap) { + return this.buildError( + request.id, + 'runtime_busy', + 'long-poll capacity reached; retry with backoff' + ) + } + if (longPoll) { + this.activeLongPolls += 1 + // Why: arm the keepalive timer only for long-polls. Short RPCs never + // touch it so the `setInterval` is never created. See §3.1. + context?.startKeepalive() + } + + try { + return await this.dispatcher.dispatch(request, { + signal: longPoll ? context?.signal : undefined + }) + } finally { + if (longPoll) { + this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) } - let newlineIndex = buffer.indexOf('\n') - while (newlineIndex !== -1) { - const rawMessage = buffer.slice(0, newlineIndex).trim() - buffer = buffer.slice(newlineIndex + 1) - if (rawMessage) { - void this.handleMessage(rawMessage).then((response) => { - socket.write(`${JSON.stringify(response)}\n`) - }) - } - newlineIndex = buffer.indexOf('\n') - } - }) + } } - private async handleMessage(rawMessage: string): Promise { + private parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { let request: RpcRequest try { request = JSON.parse(rawMessage) as RpcRequest } catch { - return this.buildError('unknown', 'bad_request', 'Invalid JSON request') + return { error: this.buildError('unknown', 'bad_request', 'Invalid JSON request') } } if (typeof request.id !== 'string' || request.id.length === 0) { - return this.buildError('unknown', 'bad_request', 'Missing request id') + return { error: this.buildError('unknown', 'bad_request', 'Missing request id') } } if (typeof request.method !== 'string' || request.method.length === 0) { - return this.buildError(request.id, 'bad_request', 'Missing RPC method') + return { error: this.buildError(request.id, 'bad_request', 'Missing RPC method') } } if (typeof request.authToken !== 'string' || request.authToken.length === 0) { - return this.buildError(request.id, 'unauthorized', 'Missing auth token') + return { error: this.buildError(request.id, 'unauthorized', 'Missing auth token') } } if (request.authToken !== this.authToken) { - return this.buildError(request.id, 'unauthorized', 'Invalid auth token') + return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } } - return this.dispatcher.dispatch(request) + return { request } + } + + // Why: WebSocket messages go through streaming dispatch which can emit + // multiple responses. Auth uses per-device tokens from the device registry. + private async handleWebSocketMessage( + rawMessage: string, + reply: (response: string) => void, + wsTransport?: WebSocketTransport, + ws?: WebSocket + ): Promise { + let request: RpcRequest + try { + request = JSON.parse(rawMessage) as RpcRequest + } catch { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request'))) + return + } + + if (typeof request.id !== 'string' || request.id.length === 0) { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Missing request id'))) + return + } + if (typeof request.method !== 'string' || request.method.length === 0) { + reply(JSON.stringify(this.buildError(request.id, 'bad_request', 'Missing RPC method'))) + return + } + + const token = + typeof (request as Record).deviceToken === 'string' + ? ((request as Record).deviceToken as string) + : null + if (!token) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) + return + } + if (!this.deviceRegistry?.validateToken(token)) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) + return + } + + // Why: associate the deviceToken with this WebSocket so ws.on('close') + // can notify the runtime which mobile client disconnected. + if (wsTransport && ws) { + wsTransport.setClientId(ws, token) + } + + const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined + await this.dispatcher.dispatchStreaming(request, reply, { connectionId }) } private buildError(id: string, code: string, message: string): RpcResponse { @@ -215,7 +462,7 @@ export class OrcaRuntimeRpcServer { const metadata: RuntimeMetadata = { runtimeId: this.runtime.getRuntimeId(), pid: this.pid, - transport: this.transport, + transports: this.transports, authToken: this.authToken, startedAt: this.runtime.getStartedAt() } diff --git a/src/main/runtime/scrollback-limits.ts b/src/main/runtime/scrollback-limits.ts new file mode 100644 index 00000000000..95308afcff8 --- /dev/null +++ b/src/main/runtime/scrollback-limits.ts @@ -0,0 +1,7 @@ +// Why: mobile subscribers hydrate the runtime headless emulator from the +// desktop renderer's xterm buffer. The renderer holds a 50k-row scrollback; +// shipping all of it across IPC + replaying through HeadlessEmulator is +// expensive and unnecessary for a phone screen. Cap at 1000 rows so the seed +// covers a meaningful amount of agent context while keeping the round-trip +// bounded. See docs/mobile-prefer-renderer-scrollback.md. +export const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 diff --git a/src/main/runtime/tls-certificate.ts b/src/main/runtime/tls-certificate.ts new file mode 100644 index 00000000000..1972d13d16e --- /dev/null +++ b/src/main/runtime/tls-certificate.ts @@ -0,0 +1,63 @@ +// Why: the WebSocket transport uses wss:// with a self-signed TLS certificate +// to prevent passive sniffing of device tokens on shared WiFi networks. The +// cert is generated once on first run and reused across restarts. The mobile +// app pins the certificate fingerprint received during QR pairing. +import { createHash } from 'crypto' +import { execSync } from 'child_process' +import { existsSync, readFileSync, chmodSync } from 'fs' +import { join } from 'path' + +const TLS_CERT_FILENAME = 'orca-tls-cert.pem' +const TLS_KEY_FILENAME = 'orca-tls-key.pem' + +export type TlsCertificate = { + cert: string + key: string + fingerprint: string +} + +export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate { + const certPath = join(userDataPath, TLS_CERT_FILENAME) + const keyPath = join(userDataPath, TLS_KEY_FILENAME) + + if (existsSync(certPath) && existsSync(keyPath)) { + const cert = readFileSync(certPath, 'utf-8') + const key = readFileSync(keyPath, 'utf-8') + const fingerprint = computeFingerprint(cert) + if (fingerprint) { + return { cert, key, fingerprint } + } + // Why: if the existing cert is malformed (e.g., from a buggy earlier + // generation), regenerate rather than failing the WebSocket transport. + } + + const keyPath_ = join(userDataPath, TLS_KEY_FILENAME) + const certPath_ = join(userDataPath, TLS_CERT_FILENAME) + + // Why: openssl is available on macOS, Linux, and Windows (via Git Bash). + // Using it avoids hand-rolling ASN.1 DER encoding which is error-prone. + execSync( + `openssl req -new -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` + + `-nodes -days 3650 -subj "/CN=Orca Runtime" ` + + `-keyout "${keyPath_}" -out "${certPath_}" 2>/dev/null` + ) + + chmodSync(keyPath_, 0o600) + chmodSync(certPath_, 0o600) + + const cert = readFileSync(certPath_, 'utf-8') + const key = readFileSync(keyPath_, 'utf-8') + return { cert, key, fingerprint: computeFingerprint(cert)! } +} + +function computeFingerprint(certPem: string): string | null { + const derMatch = certPem.match( + /-----BEGIN CERTIFICATE-----\n([\s\S]+?)\n-----END CERTIFICATE-----/ + ) + if (!derMatch?.[1]) { + return null + } + const der = Buffer.from(derMatch[1].replace(/\n/g, ''), 'base64') + const hash = createHash('sha256').update(der).digest('hex') + return `sha256:${hash}` +} diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 6fcfd9c798c..c66749a7aac 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -60,10 +60,10 @@ describe('killAllProcessesForWorktree', () => { expect(result.providerStopped).toBe(1) expect(result.registryStopped).toBe(1) - expect(localProvider.shutdown).toHaveBeenCalledWith('w1@@abcd1234', true) - expect(localProvider.shutdown).toHaveBeenCalledWith('w1-registry-1', true) - expect(localProvider.shutdown).not.toHaveBeenCalledWith('w2@@efef5678', true) - expect(localProvider.shutdown).not.toHaveBeenCalledWith('w2-registry-2', true) + expect(localProvider.shutdown).toHaveBeenCalledWith('w1@@abcd1234', { immediate: true }) + expect(localProvider.shutdown).toHaveBeenCalledWith('w1-registry-1', { immediate: true }) + expect(localProvider.shutdown).not.toHaveBeenCalledWith('w2@@efef5678', { immediate: true }) + expect(localProvider.shutdown).not.toHaveBeenCalledWith('w2-registry-2', { immediate: true }) }) it('skips the daemon prefix sweep safely when the provider uses numeric ids', async () => { @@ -81,7 +81,7 @@ describe('killAllProcessesForWorktree', () => { // Prefix sweep must kill nothing; registry sweep must still fire. expect(result.providerStopped).toBe(0) expect(result.registryStopped).toBe(1) - expect(localProvider.shutdown).toHaveBeenCalledWith('1', true) + expect(localProvider.shutdown).toHaveBeenCalledWith('1', { immediate: true }) expect(localProvider.shutdown).toHaveBeenCalledTimes(1) }) @@ -114,12 +114,12 @@ describe('killAllProcessesForWorktree', () => { listRegisteredPtysMock.mockReturnValue([]) const r1 = await killAllProcessesForWorktree('w1', { localProvider: providerA }) - expect(providerA.shutdown).toHaveBeenCalledWith('w1@@aaaa', true) + expect(providerA.shutdown).toHaveBeenCalledWith('w1@@aaaa', { immediate: true }) expect(providerB.shutdown).not.toHaveBeenCalled() expect(r1.providerStopped).toBe(1) const r2 = await killAllProcessesForWorktree('w1', { localProvider: providerB }) - expect(providerB.shutdown).toHaveBeenCalledWith('w1@@bbbb', true) + expect(providerB.shutdown).toHaveBeenCalledWith('w1@@bbbb', { immediate: true }) expect(providerB.shutdown).toHaveBeenCalledTimes(1) expect(r2.providerStopped).toBe(1) }) diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index a25d46ec482..3488d5033af 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -65,7 +65,7 @@ async function sweepProviderByPrefix(worktreeId: string, provider: IPtyProvider) continue } try { - await provider.shutdown(s.id, true) + await provider.shutdown(s.id, { immediate: true }) killed += 1 } catch { // Already dead, or the backend dropped the session — treat as success. @@ -83,7 +83,7 @@ async function sweepRegistryForWorktree( let killed = 0 for (const entry of entries) { try { - await localProvider.shutdown(entry.ptyId, true) + await localProvider.shutdown(entry.ptyId, { immediate: true }) killed += 1 } catch { /* ignore — best-effort */ diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index 5a134fc7f6b..52a0e150885 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -36,9 +36,27 @@ export const RelayErrorCode = { PermissionDenied: -33002, PathNotFound: -33003, PtyAllocationFailed: -33004, - DiskFull: -33005 + DiskFull: -33005, + TooManyStreams: -33006, + StreamProtocolError: -33007 } as const +export const JsonRpcErrorCode = { + MethodNotFound: -32601 +} as const + +// ── Streaming constants (see docs/relay-file-stream-design.md) ───── + +/** Per-chunk payload size for fs.readFileStream. Mirrors VS Code's + * `bufferSize: 256 * 1024` (vs/platform/files/node/diskFileSystemProvider.ts). + * 256KB raw → ~340KB base64, well under MAX_MESSAGE_SIZE. */ +export const STREAM_CHUNK_SIZE = 256 * 1024 + +/** Cap on concurrent in-flight streams per relay; mirrors fs.watch's + * 20-watcher cap idiom. Prevents file-descriptor exhaustion from a buggy + * client. */ +export const MAX_CONCURRENT_STREAMS = 16 + // ── JSON-RPC types ────────────────────────────────────────────────── export type JsonRpcRequest = { diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 85350f2586b..f8ba76209b2 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -167,6 +167,40 @@ describe('SshChannelMultiplexer', () => { expect(handler).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 0 }) }) + + it('typed dispatcher only fires for its method', () => { + const chunkHandler = vi.fn() + const otherHandler = vi.fn() + const generic = vi.fn() + mux.onNotificationByMethod('fs.streamChunk', chunkHandler) + mux.onNotificationByMethod('fs.streamEnd', otherHandler) + mux.onNotification(generic) + + transport.dataCallbacks[0]( + makeNotificationFrame('fs.streamChunk', { streamId: 1, seq: 0, data: 'aGk=' }, 1) + ) + + expect(chunkHandler).toHaveBeenCalledWith({ streamId: 1, seq: 0, data: 'aGk=' }) + expect(otherHandler).not.toHaveBeenCalled() + expect(generic).toHaveBeenCalledWith('fs.streamChunk', { + streamId: 1, + seq: 0, + data: 'aGk=' + }) + }) + + it('typed dispatcher unsubscribe removes only that handler', () => { + const a = vi.fn() + const b = vi.fn() + const unsubA = mux.onNotificationByMethod('fs.streamEnd', a) + mux.onNotificationByMethod('fs.streamEnd', b) + unsubA() + + transport.dataCallbacks[0](makeNotificationFrame('fs.streamEnd', { streamId: 7 }, 1)) + + expect(a).not.toHaveBeenCalled() + expect(b).toHaveBeenCalledWith({ streamId: 7 }) + }) }) describe('keepalive', () => { @@ -179,6 +213,16 @@ describe('SshChannelMultiplexer', () => { const lastFrame = transport.written.at(-1)! expect(lastFrame[0]).toBe(MessageType.KeepAlive) }) + + it('turns transport write failures into connection loss instead of throwing from the timer', () => { + const writeError = new Error('write EPIPE') + transport.write = vi.fn(() => { + throw writeError + }) + + expect(() => vi.advanceTimersByTime(5_000)).not.toThrow() + expect(mux.isDisposed()).toBe(true) + }) }) describe('dispose', () => { diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index d3d3ec9c518..11a18bebe82 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -27,6 +27,7 @@ type PendingRequest = { } export type NotificationHandler = (method: string, params: Record) => void +export type MethodNotificationHandler = (params: Record) => void const REQUEST_TIMEOUT_MS = 30_000 @@ -40,6 +41,10 @@ export class SshChannelMultiplexer { private lastReceivedAt = Date.now() private pendingRequests = new Map() private notificationHandlers: NotificationHandler[] = [] + // Why: per-method dispatch map keeps streaming consumers (fs.streamChunk, + // fs.streamEnd, fs.streamError) from accreting string-match logic in the + // generic notification listener that already serves fs.changed. + private methodNotificationHandlers = new Map>() private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = [] private keepaliveTimer: ReturnType | null = null private timeoutTimer: ReturnType | null = null @@ -85,6 +90,25 @@ export class SshChannelMultiplexer { } } + onNotificationByMethod(method: string, handler: MethodNotificationHandler): () => void { + let set = this.methodNotificationHandlers.get(method) + if (!set) { + set = new Set() + this.methodNotificationHandlers.set(method, set) + } + set.add(handler) + return () => { + const current = this.methodNotificationHandlers.get(method) + if (!current) { + return + } + current.delete(handler) + if (current.size === 0) { + this.methodNotificationHandlers.delete(method) + } + } + } + // Why: the session needs to know when the relay channel dies so it can // auto-reconnect. Without this, a relay channel close (e.g. --connect // bridge exits) leaves the session in 'ready' state with a dead mux @@ -178,6 +202,7 @@ export class SshChannelMultiplexer { } this.unackedTimestamps.clear() + this.methodNotificationHandlers.clear() this.decoder.reset() this.transport.close?.() @@ -201,7 +226,14 @@ export class SshChannelMultiplexer { const seq = this.nextOutgoingSeq++ const frame = encodeJsonRpcFrame(msg, seq, this.highestReceivedSeq) this.unackedTimestamps.set(seq, Date.now()) - this.transport.write(frame) + try { + this.transport.write(frame) + } catch (err) { + // Why: a remote reboot can make the SSH channel's stdin throw EPIPE + // from a timer/request path. Scope it to this mux instead of letting + // the Electron main process treat it as an uncaught exception. + this.handleProtocolError(err) + } } private sendKeepAlive(): void { @@ -211,7 +243,13 @@ export class SshChannelMultiplexer { const seq = this.nextOutgoingSeq++ const frame = encodeKeepAliveFrame(seq, this.highestReceivedSeq) this.unackedTimestamps.set(seq, Date.now()) - this.transport.write(frame) + try { + this.transport.write(frame) + } catch (err) { + // Why: keepalive runs on an interval; without catching transport + // write failures here, a dead SSH host can terminate the whole app. + this.handleProtocolError(err) + } } private handleFrame(frame: DecodedFrame): void { @@ -273,12 +311,19 @@ export class SshChannelMultiplexer { private handleNotification(msg: JsonRpcNotification): void { const params = msg.params ?? {} // Why: handlers may unsubscribe during iteration (via the returned disposer - // from onNotification), which splices the live array and skips the next handler. - // Iterating a snapshot prevents that. + // from onNotification / onNotificationByMethod), which mutates the live + // collection and skips the next handler. Iterating a snapshot prevents that. const snapshot = Array.from(this.notificationHandlers) for (const handler of snapshot) { handler(msg.method, params) } + const methodHandlers = this.methodNotificationHandlers.get(msg.method) + if (methodHandlers && methodHandlers.size > 0) { + const methodSnapshot = Array.from(methodHandlers) + for (const handler of methodSnapshot) { + handler(params) + } + } } private startKeepalive(): void { diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 552e6d66ace..29d89f071d8 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -1,11 +1,26 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Socket } from 'net' let eventHandlers: Map void> let connectBehavior: 'ready' | 'error' = 'ready' let connectErrorMessage = '' -vi.mock('ssh2', () => ({ - Client: class MockSshClient { +type MockSshClient = { + setNoDelay: ReturnType + _sock: Socket | undefined +} +let clientInstances: MockSshClient[] = [] + +vi.mock('ssh2', () => { + class MockSshClient { + setNoDelay = vi.fn() + // Why: production code reads `client._sock` and checks `instanceof net.Socket` + // to decide which log line to emit. A real Socket instance lets the test + // exercise the "enabled" branch instead of the "skipped (proxy socket)" branch. + _sock: Socket | undefined = new Socket() + constructor() { + clientInstances.push(this) + } on(event: string, handler: (...args: unknown[]) => void) { eventHandlers?.set(event, handler) } @@ -23,7 +38,8 @@ vi.mock('ssh2', () => ({ exec() {} sftp() {} } -})) + return { Client: MockSshClient } +}) vi.mock('./ssh-system-fallback', () => ({ spawnSystemSsh: vi.fn().mockReturnValue({ @@ -67,6 +83,7 @@ describe('SshConnection', () => { eventHandlers = new Map() connectBehavior = 'ready' connectErrorMessage = '' + clientInstances = [] }) it('transitions to connected on successful connect', async () => { @@ -82,6 +99,39 @@ describe('SshConnection', () => { ) }) + it('enables TCP_NODELAY on the ssh2 client after ready', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + + expect(clientInstances).toHaveLength(1) + expect(clientInstances[0].setNoDelay).toHaveBeenCalledWith(true) + }) + + it('enables TCP_NODELAY on the new ssh2 client after a reconnect cycle', async () => { + // Why: guards the "Nagle is re-enabled because someone refactored only + // the initial connect path" regression class. attemptConnect bumps + // connectGeneration on every call, and both the initial connect and the + // explicit reconnect path go through doSsh2Connect → client.on('ready'). + // The new client must also receive setNoDelay(true). + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + expect(clientInstances).toHaveLength(1) + expect(clientInstances[0].setNoDelay).toHaveBeenCalledWith(true) + + // Simulate the reconnect path: a fresh attemptConnect run via the + // internal helper that scheduleReconnect uses. Easiest from the public + // API is to call connect() again — disposed/connected guard rejects, so + // we exercise the path via a private call. Use the bracket-access + // form to keep the test free of `any` casts. + const privateConn = conn as unknown as { + attemptConnect: () => Promise + } + await privateConn.attemptConnect() + + expect(clientInstances).toHaveLength(2) + expect(clientInstances[1].setNoDelay).toHaveBeenCalledWith(true) + }) + it('transitions through connecting → connected states', async () => { const states: string[] = [] const callbacks = createCallbacks({ @@ -162,6 +212,7 @@ describe('SshConnectionManager', () => { eventHandlers = new Map() connectBehavior = 'ready' connectErrorMessage = '' + clientInstances = [] }) it('connect creates and stores a connection', async () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 6c986fbcb48..4e8e88a4239 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines -- Why: SSH connection lifecycle, credential retries, reconnect policy, and transport fallback are intentionally co-located so state transitions stay auditable in one file. */ +import * as net from 'net' import { Client as SshClient } from 'ssh2' import type { ChildProcess } from 'child_process' import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2' @@ -55,6 +56,16 @@ export class SshConnection { return { ...this.target } } + // Why: exposes whether a passphrase/password is already cached in-memory for + // this connection. Used by ssh:needsPassphrasePrompt so callers can decide + // whether a manual-reconnect will prompt or go through silently. Without this, + // lastRequiredPassphrase stays true across the session even after the user + // has entered the credential once, causing redundant "enter passphrase" + // prompts on disconnect→reconnect cycles within a single app session. + hasCachedCredential(): boolean { + return this.cachedPassphrase != null || this.cachedPassword != null + } + async exec(cmd: string): Promise { if (!this.client) { throw new Error('Not connected') @@ -110,7 +121,9 @@ export class SshConnection { this.proxyProcess = null const connectGeneration = ++this.connectGeneration - const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(() => null) + const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch( + () => null + ) const config = buildConnectConfig(this.target, resolved) // Why: ssh2 doesn't support ProxyCommand/ProxyJump natively. Spawn the @@ -208,6 +221,22 @@ export class SshConnection { settled = true this.client = client this.proxyProcess = null + // Why: ssh2 leaves Nagle's algorithm on by default. For single-byte + // keystrokes through a remote PTY this stacks with the kernel's + // delayed-ACK timer and adds up to ~40 ms per keystroke. OpenSSH's + // `ssh` sets TCP_NODELAY whenever a PTY is allocated; we mirror that + // because every channel we open over this connection (PTY data, + // JSON-RPC requests, port-scan probes) is latency-sensitive. No-op + // for proxy-command / proxy-jump connections where _sock is a custom + // Duplex; that case relies on the proxy program's own TCP behavior, + // same as native ssh. + const sock = (client as unknown as { _sock?: { setNoDelay?: unknown } })._sock + if (sock instanceof net.Socket) { + console.warn(`[ssh] TCP_NODELAY enabled for ${this.target.label}`) + } else { + console.warn(`[ssh] TCP_NODELAY skipped for ${this.target.label} (proxy socket)`) + } + client.setNoDelay(true) this.setState('connected') this.setupDisconnectHandler(client) resolve() diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts new file mode 100644 index 00000000000..b06ab52ed4e --- /dev/null +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -0,0 +1,302 @@ +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { STREAM_CHUNK_SIZE, JsonRpcErrorCode, RelayErrorCode } from './relay-protocol' +import type { FileReadResult } from '../providers/types' + +const RESULT_ENCODING_BASE64 = 'base64' +const SENTINEL_STREAM_ID = -1 + +const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 +const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 + +type StreamMetadataResponse = { + streamId?: number + totalSize: number + isBinary: boolean + isImage?: boolean + mimeType?: string + resultEncoding?: 'base64' | 'utf-8' + empty?: boolean +} + +export function isMethodNotFoundError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false + } + const code = (err as { code?: unknown }).code + return code === JsonRpcErrorCode.MethodNotFound +} + +export class StreamProtocolError extends Error { + readonly code = RelayErrorCode.StreamProtocolError + constructor(message: string) { + super(message) + } +} + +export async function readFileViaStream( + mux: SshChannelMultiplexer, + filePath: string +): Promise { + // Why: subscribe BEFORE awaiting the metadata response so a chunk arriving + // immediately after the response cannot beat the listener registration. + // streamIdRef stays at SENTINEL_STREAM_ID until metadata resolves; chunk + // handlers compare against it and drop unmatched ids cleanly. + const streamIdRef = { current: SENTINEL_STREAM_ID } + const unsubscribers: (() => void)[] = [] + const cleanup = (): void => { + while (unsubscribers.length > 0) { + const fn = unsubscribers.pop() + try { + fn?.() + } catch { + // Best-effort cleanup + } + } + } + + return new Promise((resolve, reject) => { + let buffer: Buffer | null = null + let resultEncoding: 'base64' | 'utf-8' = RESULT_ENCODING_BASE64 + let isBinary = false + let isImage: boolean | undefined + let mimeType: string | undefined + let totalSize = 0 + let expectedSeq = 0 + let receivedChunks = 0 + let totalChunks = 0 + let settled = false + + // Why: chunk/end/error frames may arrive in the same dispatch tick as the + // metadata response. Queue them until streamIdRef is set, then drain. + type PendingFrame = + | { kind: 'chunk'; params: Record } + | { kind: 'end'; params: Record } + | { kind: 'error'; params: Record } + const pending: PendingFrame[] = [] + let metadataReady = false + + const cancel = (): void => { + if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { + try { + mux.notify('fs.cancelStream', { streamId: streamIdRef.current }) + } catch { + // Best-effort + } + } + } + + const fail = (err: Error): void => { + if (settled) { + return + } + settled = true + cancel() + cleanup() + reject(err) + } + + const succeed = (value: FileReadResult): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(value) + } + + const handleChunk = (params: Record): void => { + if (settled) { + return + } + const id = params.streamId as number | undefined + if (id !== streamIdRef.current) { + return + } + const seq = params.seq as number + const data = params.data as string + if (typeof seq !== 'number' || typeof data !== 'string') { + fail(new StreamProtocolError(`Malformed chunk for stream ${id}`)) + return + } + if (seq !== expectedSeq) { + fail( + new StreamProtocolError( + `Out-of-order chunk for stream ${id}: expected ${expectedSeq}, got ${seq}` + ) + ) + return + } + const offset = seq * STREAM_CHUNK_SIZE + const decoded = Buffer.from(data, 'base64') + if (offset + decoded.length > totalSize) { + fail( + new StreamProtocolError( + `Chunk overflows declared totalSize: offset=${offset} len=${decoded.length} total=${totalSize}` + ) + ) + return + } + if (!buffer) { + fail(new StreamProtocolError(`Chunk arrived before metadata for stream ${id}`)) + return + } + decoded.copy(buffer, offset) + expectedSeq += 1 + receivedChunks += 1 + } + + const handleEnd = (params: Record): void => { + if (settled) { + return + } + const id = params.streamId as number | undefined + if (id !== streamIdRef.current) { + return + } + if (receivedChunks !== totalChunks) { + fail( + new StreamProtocolError( + `Chunk count mismatch for stream ${id}: expected ${totalChunks}, received ${receivedChunks}` + ) + ) + return + } + if (!buffer) { + fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`)) + return + } + const content = + resultEncoding === RESULT_ENCODING_BASE64 + ? buffer.toString('base64') + : buffer.toString('utf-8') + succeed({ + content, + isBinary, + ...(isImage !== undefined ? { isImage } : {}), + ...(mimeType !== undefined ? { mimeType } : {}) + }) + } + + const handleStreamError = (params: Record): void => { + if (settled) { + return + } + const id = params.streamId as number | undefined + if (id !== streamIdRef.current) { + return + } + const message = (params.message as string | undefined) ?? 'stream error' + const code = (params.code as string | undefined) ?? 'ESTREAMERROR' + const err = new Error(message) as Error & { code: string } + err.code = code + fail(err) + } + + const drainPending = (): void => { + while (!settled && pending.length > 0) { + const frame = pending.shift()! + if (frame.kind === 'chunk') { + handleChunk(frame.params) + } else if (frame.kind === 'end') { + handleEnd(frame.params) + } else { + handleStreamError(frame.params) + } + } + } + + unsubscribers.push( + mux.onNotificationByMethod('fs.streamChunk', (params) => { + if (!metadataReady) { + pending.push({ kind: 'chunk', params }) + return + } + handleChunk(params) + }) + ) + unsubscribers.push( + mux.onNotificationByMethod('fs.streamEnd', (params) => { + if (!metadataReady) { + pending.push({ kind: 'end', params }) + return + } + handleEnd(params) + }) + ) + unsubscribers.push( + mux.onNotificationByMethod('fs.streamError', (params) => { + if (!metadataReady) { + pending.push({ kind: 'error', params }) + return + } + handleStreamError(params) + }) + ) + + const onDispose = mux.onDispose((reason) => { + const message = + reason === 'connection_lost' + ? 'SSH connection lost, reconnecting...' + : 'Multiplexer disposed' + const err = new Error(message) as Error & { code: string } + err.code = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' + fail(err) + }) + unsubscribers.push(onDispose) + + void mux + .request('fs.readFileStream', { filePath }) + .then((rawMetadata) => { + if (settled) { + return + } + const metadata = rawMetadata as StreamMetadataResponse + isBinary = metadata.isBinary + isImage = metadata.isImage + mimeType = metadata.mimeType + resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 + + if (metadata.empty) { + succeed({ + content: '', + isBinary: metadata.isBinary, + ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), + ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) + }) + return + } + + if (typeof metadata.streamId !== 'number') { + fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) + return + } + + const cap = metadata.isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (metadata.totalSize < 0 || metadata.totalSize > cap) { + streamIdRef.current = metadata.streamId + fail( + new StreamProtocolError( + `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` + ) + ) + return + } + + totalSize = metadata.totalSize + totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) + try { + buffer = Buffer.alloc(totalSize) + } catch (err) { + streamIdRef.current = metadata.streamId + fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) + return + } + streamIdRef.current = metadata.streamId + metadataReady = true + drainPending() + }) + .catch((err) => { + fail(err as Error) + }) + }) +} diff --git a/src/main/ssh/ssh-port-forward.ts b/src/main/ssh/ssh-port-forward.ts index a1e927245b1..610e78b62c9 100644 --- a/src/main/ssh/ssh-port-forward.ts +++ b/src/main/ssh/ssh-port-forward.ts @@ -69,6 +69,7 @@ export class SshPortForwardManager { } socket.pipe(channel).pipe(socket) channel.on('close', () => socket.destroy()) + channel.on('error', () => socket.destroy()) socket.on('close', () => channel.close()) }) }) diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts new file mode 100644 index 00000000000..d8623267769 --- /dev/null +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -0,0 +1,166 @@ +// Cross-version isolation guard. +// +// Why: this test is the executable form of the "Pattern Note" in +// docs/ssh-relay-versioned-install-dirs.md — it asserts that a v2 deploy +// targeting a remote where a v1 daemon is already running NEVER touches +// v1's install dir or socket. Without this test a future refactor that +// collapses to a shared dir passes every other unit test and re-introduces +// the original "stale daemon serves new client" bug. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+v2hash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + execCommand: vi.fn(), + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand } from './ssh-relay-deploy-helpers' +import type { SshConnection } from './ssh-connection' + +function makeMockConnection(): SshConnection { + return { + exec: vi.fn().mockResolvedValue({ + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }), + sftp: vi.fn().mockResolvedValue({ + mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), + on: vi.fn(), + once: vi.fn(), + createWriteStream: vi.fn().mockReturnValue({ + on: vi.fn((_event: string, cb: () => void) => { + if (_event === 'close') { + setTimeout(cb, 0) + } + }), + once: vi.fn((_event: string, cb: () => void) => { + if (_event === 'close') { + setTimeout(cb, 0) + } + }), + end: vi.fn() + }), + end: vi.fn() + }) + } as unknown as SshConnection +} + +describe('cross-version isolation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('a v2 deploy never references the v1 install dir or v1 socket path', async () => { + const conn = makeMockConnection() + const mockExec = vi.mocked(execCommand) + + // Simulated remote where: + // v1 dir = ~/.orca-remote/relay-0.1.0+v1hash/ (live daemon, listening) + // v2 dir = ~/.orca-remote/relay-0.1.0+v2hash/ (does not yet exist) + // The v2 client has fullVersion='0.1.0+v2hash' (from the fs mock above). + // + // We feed enough exec results to walk through the deploy: platform, + // $HOME, isRelayAlreadyInstalled probe, lock acquire, upload (no exec), + // npm install, finalize, socket probe, socket poll, then GC scan. + const responses: string[] = [ + 'Linux x86_64', // uname -sm + '/home/u', // echo $HOME + 'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist) + '', // mkdir -p remoteRelayDir (v2) + 'OK', // mkdir lock OK + 'MISSING', // re-probe after lock → still missing → proceed with install + '', // mkdir remoteDir (uploadRelay) + '', // chmod +x node + '', // npm install + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', // node -e require() load-test (post-install verify) + '', // rm -f probe-stderr (best-effort cleanup after probe resolved) + '', // touch .install-complete (finalizeInstall) + '', // rm -rf .install-lock + 'DEAD', // launch socket probe + 'READY', // socket poll + // GC scan begins here + 'relay-0.1.0+v1hash\nrelay-0.1.0+v2hash\n', // ls listing + 'OPEN', // v1 lock probe (siblings only — current dir is v2) + 'COMPLETE', // v1 .install-complete probe + 'ALIVE' // v1 socket probe → live → SKIP (don't GC v1) + ] + for (const r of responses) { + mockExec.mockResolvedValueOnce(r) + } + + await deployAndLaunchRelay(conn) + + const allCmds = [ + ...mockExec.mock.calls.map(([, c]) => c), + ...vi.mocked(conn.exec).mock.calls.map(([c]) => c as string) + ] + + // (a) the v2 deploy creates dirs/files under relay-0.1.0+v2hash + expect(allCmds.some((c) => c.includes('relay-0.1.0+v2hash'))).toBe(true) + + // (b) the v2 launch and connect socket paths are rooted in v2 dir, never v1 + const launchAndConnectCmds = vi + .mocked(conn.exec) + .mock.calls.map(([c]) => c as string) + .filter((c) => c.includes('--sock-path')) + expect(launchAndConnectCmds.length).toBeGreaterThan(0) + for (const cmd of launchAndConnectCmds) { + expect(cmd).toContain('relay-0.1.0+v2hash') + expect(cmd).not.toContain('relay-0.1.0+v1hash') + } + + // (c) GC observes v1 has a live socket and never issues an rm -rf for it + const v1RemoveCmds = allCmds.filter( + (c) => c.includes('rm -rf') && c.includes('relay-0.1.0+v1hash') + ) + expect(v1RemoveCmds).toHaveLength(0) + + // (d) blanket isolation: every command that mentions v1hash MUST be a + // GC liveness probe (`ls`, `test -d`, `test -f`, or `for f in .../*.sock`) + // — never a write, mkdir, chmod, touch, rm, node launch, or socket poll. + // This prevents a future refactor that accidentally writes to the v1 dir + // (e.g. shared install-complete, upload over symlink) from passing. + const v1Refs = allCmds.filter((c) => c.includes('relay-0.1.0+v1hash')) + for (const cmd of v1Refs) { + const isReadOnlyProbe = + /^\s*ls\b/.test(cmd) || + /\btest -d\b/.test(cmd) || + /\btest -f\b/.test(cmd) || + /\btest -S\b/.test(cmd) || + /\bfor f in .*\.sock\b/.test(cmd) + expect(isReadOnlyProbe, `unexpected v1 reference: ${cmd}`).toBe(true) + } + }) +}) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 008b758e173..3c30d23318b 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -1,8 +1,12 @@ import { EventEmitter } from 'events' import { describe, expect, it, vi } from 'vitest' import type { ClientChannel } from 'ssh2' -import { waitForSentinel } from './ssh-relay-deploy-helpers' +import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { RELAY_SENTINEL } from './relay-protocol' +import { + RelayVersionMismatchError, + RELAY_EXIT_CODE_VERSION_MISMATCH +} from './ssh-relay-version-mismatch-error' function createMockChannel(): ClientChannel { return Object.assign(new EventEmitter(), { @@ -39,4 +43,84 @@ describe('waitForSentinel', () => { expect(chunks).toEqual(['same-chunk-frame']) }) + + it('rejects on relay channel errors before the sentinel instead of emitting uncaught errors', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + + expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() + await expect(transportPromise).rejects.toThrow('remote host rebooted') + }) + + it('notifies transport close subscribers on relay channel errors after the sentinel', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + + channel.emit('data', Buffer.from(RELAY_SENTINEL)) + const transport = await transportPromise + const onClose = vi.fn() + transport.onClose(onClose) + + expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('translates a pre-sentinel exit-42 + close into RelayVersionMismatchError', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + + channel.stderr.emit( + 'data', + Buffer.from( + '[relay-connect] Handshake mismatch: expected=0.1.0+aaa, daemon=0.1.0+bbb; exiting 42\n' + ) + ) + channel.emit('exit', RELAY_EXIT_CODE_VERSION_MISMATCH) + channel.emit('close') + + await expect(transportPromise).rejects.toBeInstanceOf(RelayVersionMismatchError) + await transportPromise.catch((err: RelayVersionMismatchError) => { + expect(err.expected).toBe('0.1.0+aaa') + expect(err.got).toBe('0.1.0+bbb') + }) + }) + + it('translates a pre-sentinel exit-42 even when the version detail is missing', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + + channel.stderr.emit('data', Buffer.from('boom\n')) + channel.emit('exit', RELAY_EXIT_CODE_VERSION_MISMATCH) + channel.emit('close') + + await expect(transportPromise).rejects.toBeInstanceOf(RelayVersionMismatchError) + }) + + it('rejects with a generic error (not RelayVersionMismatchError) on a non-42 exit code', async () => { + const channel = createMockChannel() + const transportPromise = waitForSentinel(channel) + + channel.stderr.emit('data', Buffer.from('node: bad bytecode\n')) + channel.emit('exit', 1) + channel.emit('close') + + await expect(transportPromise).rejects.toThrow(/Relay process exited before ready/) + await transportPromise.catch((err: unknown) => { + expect(err).not.toBeInstanceOf(RelayVersionMismatchError) + }) + }) +}) + +describe('execCommand', () => { + it('rejects on command channel errors instead of emitting uncaught errors', async () => { + const channel = createMockChannel() + const conn = { + exec: vi.fn().mockResolvedValue(channel) + } + const commandPromise = execCommand(conn as never, 'uname -sm') + + await Promise.resolve() + expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() + await expect(commandPromise).rejects.toThrow('remote host rebooted') + }) }) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index f2511c9d948..4b3146a0cd1 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -2,6 +2,10 @@ import type { ClientChannel } from 'ssh2' import type { SshConnection } from './ssh-connection' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' +import { + RelayVersionMismatchError, + RELAY_EXIT_CODE_VERSION_MISMATCH +} from './ssh-relay-version-mismatch-error' export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload' @@ -10,21 +14,57 @@ export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload' export function waitForSentinel(channel: ClientChannel): Promise { return new Promise((resolve, reject) => { let sentinelReceived = false + let settled = false let stderrOutput = '' let bufferedStdout = Buffer.alloc(0) let closedAfterSentinel = false + // Why: ssh2 fires 'exit' BEFORE 'close' with the remote exit code. + // Capturing it here lets us translate exit-42 (relay handshake mismatch) + // into a typed RelayVersionMismatchError so the relay-lost retry loop + // can skip backoff for this terminal condition. + let lastExitCode: number | null = null + + // Why: when the sentinel timeout fires we close the channel and wait a + // short grace window for the 'close' handler to surface the typed + // RelayVersionMismatchError if the remote --connect actually exited 42. + // Settling here unconditionally would let a slow exit-42 (e.g. a remote + // with congested SSH multiplex or a paused VM) be misclassified as a + // generic timeout — which routes through the relay-lost retry backoff + // and re-introduces the failure mode the typed error was meant to + // prevent. + const TIMEOUT_GRACE_MS = 500 + let timeoutFired = false + let timeoutGraceTimer: ReturnType | null = null const timeout = setTimeout(() => { - if (!sentinelReceived) { - channel.close() - reject( - new Error( - `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` + timeoutFired = true + channel.close() + timeoutGraceTimer = setTimeout(() => { + if (!settled) { + settled = true + reject( + new Error( + `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` + ) ) - ) - } + } + }, TIMEOUT_GRACE_MS) }, RELAY_SENTINEL_TIMEOUT_MS) + const cancelTimers = (): void => { + clearTimeout(timeout) + if (timeoutGraceTimer) { + clearTimeout(timeoutGraceTimer) + timeoutGraceTimer = null + } + } + + channel.on('exit', (code: number | null) => { + if (typeof code === 'number') { + lastExitCode = code + } + }) + const MAX_BUFFER_CAP = 64 * 1024 channel.stderr.on('data', (data: Buffer) => { stderrOutput += data.toString('utf-8') @@ -36,20 +76,64 @@ export function waitForSentinel(channel: ClientChannel): Promise void)[] = [] const closeCallbacks: (() => void)[] = [] - channel.on('close', () => { - if (!sentinelReceived) { - clearTimeout(timeout) - reject( - new Error( - `Relay process exited before ready.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` - ) - ) + const notifyClosed = (): void => { + if (closedAfterSentinel) { return } closedAfterSentinel = true for (const cb of closeCallbacks) { cb() } + } + + const failOrClose = (err: Error): void => { + cancelTimers() + if (!sentinelReceived) { + if (!settled) { + settled = true + reject(err) + } + return + } + notifyClosed() + } + + // Why: SSH channel streams emit `error` when the remote host disappears. + // Unhandled EventEmitter errors are process-fatal, so convert them into + // startup rejection before the sentinel and transport close after it. + channel.on('error', (err: Error) => failOrClose(err)) + channel.stderr.on('error', (err: Error) => failOrClose(err)) + + channel.on('close', () => { + if (!sentinelReceived) { + cancelTimers() + if (!settled) { + settled = true + // Why: a wire-handshake mismatch on the daemon side closes the + // socket; --connect prints the mismatch detail to stderr and exits + // with code 42 BEFORE writing the sentinel. Translate that into a + // typed RelayVersionMismatchError so the retry loop in ssh.ts can + // distinguish a recoverable transport drop from this terminal + // condition and skip backoff. The check still wins over a fired + // timeout because the timeout handler defers settling for a small + // grace window so the close handler can deliver the exit code. + if (lastExitCode === RELAY_EXIT_CODE_VERSION_MISMATCH) { + const { expected, got } = parseHandshakeMismatchStderr(stderrOutput) + reject(new RelayVersionMismatchError(expected, got, stderrOutput.trim())) + return + } + const timeoutSuffix = timeoutFired + ? ` (after ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s sentinel timeout)` + : '' + reject( + new Error( + `Relay process exited before ready${timeoutSuffix}.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` + ) + ) + } + return + } + notifyClosed() }) // Why: data arriving in the same TCP chunk as the sentinel is buffered @@ -79,7 +163,7 @@ export function waitForSentinel(channel: ClientChannel): Promise 0) { pendingAfterSentinel = afterSentinel } + settled = true const transport: MultiplexerTransport = { write: (buf: Buffer) => channel.stdin.write(buf), @@ -139,6 +224,19 @@ export async function execCommand(conn: SshConnection, command: string): Promise } }, EXEC_TIMEOUT_MS) + const fail = (err: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + reject(err) + } + + // Why: remote reboot tears down exec channels with stream errors. Without + // scoped listeners, Node treats those as uncaught exceptions. + channel.on('error', fail) + channel.stderr.on('error', fail) channel.on('data', (data: Buffer) => { stdout += data.toString('utf-8') }) @@ -214,3 +312,18 @@ export async function resolveRemoteNodePath(conn: SshConnection): Promise, daemon=" so the typed error carries +// actionable detail. Best-effort: returns undefined fields if the regex +// doesn't match, preserving the raw stderr verbatim for diagnostics. +function parseHandshakeMismatchStderr(stderr: string): { + expected: string | undefined + got: string | undefined +} { + const match = /expected=([^,\s]+),\s*daemon=([^\s;]+)/.exec(stderr) + if (!match) { + return { expected: undefined, got: undefined } + } + return { expected: match[1], got: match[2] } +} diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 145cfa6cdc9..362b60ac3d7 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -4,9 +4,13 @@ vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) +// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront +// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report +// the local relay package as existing AND return a content-hashed version +// string so readLocalFullVersion succeeds. vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn() + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') })) vi.mock('./relay-protocol', () => ({ @@ -28,6 +32,19 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) +// Why: the versioned-install module shells out to the remote for install +// state, lock acquisition, and GC. Tests stub these to no-ops so the deploy +// happy-path is exercised without a real SSH connection. +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) @@ -70,8 +87,7 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME - mockExecCommand.mockResolvedValueOnce('OK') // check relay exists - mockExecCommand.mockResolvedValueOnce('0.1.0') // version check + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe mockExecCommand.mockResolvedValueOnce('READY') // socket poll @@ -85,8 +101,7 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) mockExecCommand.mockResolvedValueOnce('Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') - mockExecCommand.mockResolvedValueOnce('OK') - mockExecCommand.mockResolvedValueOnce('0.1.0') + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe mockExecCommand.mockResolvedValueOnce('READY') // socket poll @@ -97,6 +112,28 @@ describe('deployAndLaunchRelay', () => { expect(progress).toContain('Starting relay...') }) + it('uses a content-hashed versioned remote install directory', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('/home/user') + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + mockExecCommand.mockResolvedValueOnce('DEAD') + mockExecCommand.mockResolvedValueOnce('READY') + + await deployAndLaunchRelay(conn) + + // The launch + connect commands include the versioned dir path. + const execArgs = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) + const allCmds = [...execArgs, ...mockExecCommand.mock.calls.map(([, cmd]) => cmd)] + const sawVersionedDir = allCmds.some((cmd) => + cmd.includes('/.orca-remote/relay-0.1.0+abcdef012345') + ) + expect(sawVersionedDir).toBe(true) + const sawLegacyDir = allCmds.some((cmd) => cmd.includes('relay-v0.1.0')) + expect(sawLegacyDir).toBe(false) + }) + it('has a 120-second overall timeout', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) @@ -125,14 +162,12 @@ describe('deployAndLaunchRelay', () => { mockExecCommand .mockResolvedValueOnce('Linux x86_64') // uname A .mockResolvedValueOnce('/home/user') // $HOME A - .mockResolvedValueOnce('OK') // exists A - .mockResolvedValueOnce('0.1.0') // version A + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe A .mockResolvedValueOnce('DEAD') // probe A .mockResolvedValueOnce('READY') // poll A .mockResolvedValueOnce('Linux x86_64') // uname B .mockResolvedValueOnce('/home/user') // $HOME B - .mockResolvedValueOnce('OK') // exists B - .mockResolvedValueOnce('0.1.0') // version B + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe B .mockResolvedValueOnce('DEAD') // probe B .mockResolvedValueOnce('READY') // poll B diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index fe51892f138..03dbeaf038b 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -1,14 +1,13 @@ import { join } from 'path' +/* eslint-disable max-lines -- Why: the relay-deploy module owns one cohesive + contract — version detection, install-locked deploy, native-deps probe, + relay launch, and GC — and splitting risks drift between the install + sequence and the GC's live-socket invariant. */ import { existsSync } from 'fs' import { app } from 'electron' import { createHash } from 'crypto' import type { SshConnection } from './ssh-connection' -import { - RELAY_VERSION, - RELAY_REMOTE_DIR, - parseUnameToRelayPlatform, - type RelayPlatform -} from './relay-protocol' +import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { uploadDirectory, @@ -16,6 +15,15 @@ import { execCommand, resolveRemoteNodePath } from './ssh-relay-deploy-helpers' +import { + readLocalFullVersion, + computeRemoteRelayDir, + isRelayAlreadyInstalled, + acquireInstallLock, + finalizeInstall, + abandonInstall, + gcOldRelayVersions +} from './ssh-relay-versioned-install' import { shellEscape } from './ssh-connection-utils' export type RelayDeployResult = { @@ -79,6 +87,19 @@ async function deployAndLaunchRelayInner( } console.log(`[ssh-relay] Platform: ${platform}`) + const localRelayDir = getLocalRelayPath(platform) + if (!localRelayDir) { + throw new Error( + `Relay package for ${platform} not found locally. ` + + `This may be a packaging issue — try reinstalling Orca.` + ) + } + // Why: read the content-hashed full version from the local build's .version + // file. Used as both the remote dir name and the wire-handshake version. + // Throws on missing/empty rather than silently falling back — see + // docs/ssh-relay-versioned-install-dirs.md "Data Flow: Upstream Error". + const fullVersion = readLocalFullVersion(localRelayDir) + // Why: SFTP does not expand `~`, so we must resolve the remote home directory // explicitly. `echo $HOME` over exec gives us the absolute path. const remoteHome = (await execCommand(conn, 'echo $HOME')).trim() @@ -89,24 +110,48 @@ async function deployAndLaunchRelayInner( if (!remoteHome || !remoteHome.startsWith('/') || /[\u0000\r\n]/.test(remoteHome)) { throw new Error(`Remote $HOME is not a valid path: ${remoteHome.slice(0, 100)}`) } - const remoteRelayDir = `${remoteHome}/${RELAY_REMOTE_DIR}/relay-v${RELAY_VERSION}` + const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) onProgress?.('Checking existing relay...') - const localRelayDir = getLocalRelayPath(platform) - const alreadyDeployed = await checkRelayExists(conn, remoteRelayDir, localRelayDir) - console.log(`[ssh-relay] Already deployed: ${alreadyDeployed}`) + const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir) + console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) - if (!alreadyDeployed) { - onProgress?.('Uploading relay...') - console.log('[ssh-relay] Uploading relay...') - await uploadRelay(conn, platform, remoteRelayDir) - console.log('[ssh-relay] Upload complete') + if (alreadyInstalled) { + await repairInstalledNativeDeps(conn, remoteRelayDir, platform) + } else { + // Why: serialize concurrent first-installs of the same version against + // each other via an atomic mkdir lock. The losing caller polls and either + // re-checks `alreadyInstalled` (now true) or steals a stale lock. + await acquireInstallLock(conn, remoteRelayDir) + try { + // Re-probe after acquiring the lock — a sibling installer may have + // finished while we were waiting. + if (!(await isRelayAlreadyInstalled(conn, remoteRelayDir))) { + onProgress?.('Uploading relay...') + console.log('[ssh-relay] Uploading relay...') + await uploadRelay(conn, platform, remoteRelayDir, fullVersion) + console.log('[ssh-relay] Upload complete') - onProgress?.('Installing native dependencies...') - console.log('[ssh-relay] Installing node-pty...') - await installNativeDeps(conn, remoteRelayDir) - console.log('[ssh-relay] Native deps installed') + onProgress?.('Installing native dependencies...') + console.log('[ssh-relay] Installing native dependencies...') + await installNativeDeps(conn, remoteRelayDir, platform) + console.log('[ssh-relay] Native deps installed') + + // Why: write `.install-complete` BEFORE releasing the lock so a + // sibling never observes the dir as "complete but locked", which + // would lead GC to skip a recoverable dir indefinitely. + await finalizeInstall(conn, remoteRelayDir) + } else { + await abandonInstall(conn, remoteRelayDir) + } + } catch (err) { + // Why: leave a partial install dir in place (no `.install-complete`) + // so the next deploy detects the partial and re-runs upload + install. + // Just release the lock so a concurrent caller can retry. + await abandonInstall(conn, remoteRelayDir) + throw err + } } onProgress?.('Starting relay...') @@ -114,6 +159,11 @@ async function deployAndLaunchRelayInner( const transport = await launchRelay(conn, remoteRelayDir, graceTimeSeconds, relayInstanceId) console.log('[ssh-relay] Relay started successfully') + // Why: best-effort cleanup of unreferenced sibling version dirs. Errors + // are logged inside gcOldRelayVersions and never propagate, so a GC failure + // can never block the user from connecting. + void gcOldRelayVersions(conn, remoteHome, remoteRelayDir).catch(() => {}) + return { transport, platform } } @@ -126,47 +176,11 @@ async function detectRemotePlatform(conn: SshConnection): Promise { - try { - const output = await execCommand( - conn, - `test -f ${shellEscape(`${remoteDir}/relay.js`)} && echo OK || echo MISSING` - ) - if (output.trim() !== 'OK') { - return false - } - - // Why: compare against the local .version file content (which includes a - // content hash) so any code change triggers re-deploy, even without bumping - // RELAY_VERSION. Falls back to the bare RELAY_VERSION for safety. - let expectedVersion = RELAY_VERSION - if (localRelayDir) { - try { - const { readFileSync } = await import('fs') - expectedVersion = readFileSync(join(localRelayDir, '.version'), 'utf-8').trim() - } catch { - /* fall back to RELAY_VERSION */ - } - } - - const versionOutput = await execCommand( - conn, - `cat ${shellEscape(`${remoteDir}/.version`)} 2>/dev/null || echo MISSING` - ) - return versionOutput.trim() === expectedVersion - } catch { - return false - } -} - async function uploadRelay( conn: SshConnection, platform: RelayPlatform, - remoteDir: string + remoteDir: string, + fullVersion: string ): Promise { const localRelayDir = getLocalRelayPath(platform) if (!localRelayDir || !existsSync(localRelayDir)) { @@ -191,36 +205,80 @@ async function uploadRelay( // Make the node binary executable await execCommand(conn, `chmod +x ${shellEscape(`${remoteDir}/node`)} 2>/dev/null; true`) - // Why: version marker includes a content hash so code changes trigger - // re-deploy even without bumping RELAY_VERSION. Read from the local build - // output so the remote marker matches exactly what checkRelayExists expects. - // Why: we write the version file via SFTP instead of a shell command to - // avoid shell injection — the version string could contain characters - // that break or escape single-quoted shell interpolation. - let versionString = RELAY_VERSION - const localVersionFile = join(localRelayDir, '.version') - if (existsSync(localVersionFile)) { - const { readFileSync } = await import('fs') - versionString = readFileSync(localVersionFile, 'utf-8').trim() - } + // Why: write `.version` via SFTP rather than shell to avoid quoting issues + // with content-hashed version strings. The remote daemon reads this same + // file on startup so the wire-handshake validates against it. const versionSftp = await conn.sftp() try { await new Promise((resolve, reject) => { const ws = versionSftp.createWriteStream(`${remoteDir}/.version`) ws.on('close', resolve) ws.on('error', reject) - ws.end(versionString) + ws.end(fullVersion) }) } finally { versionSftp.end() } } -// Why: node-pty is a native addon that can't be bundled by esbuild. It must -// be compiled on the remote host against its Node.js version and OS. We run -// `npm init -y && npm install node-pty` in the relay directory so -// `require('node-pty')` resolves to the local node_modules. -async function installNativeDeps(conn: SshConnection, remoteDir: string): Promise { +const RELAY_NATIVE_DEPS = ['node-pty', '@parcel/watcher'] as const + +async function hasRequiredNativeDeps(conn: SshConnection, remoteDir: string): Promise { + const nodePath = await resolveRemoteNodePath(conn) + const nodeBinDir = nodePath.replace(/\/node$/, '') + const escapedDir = shellEscape(remoteDir) + const escapedBinDir = shellEscape(nodeBinDir) + const escapedNode = shellEscape(nodePath) + try { + const probe = await execCommand( + conn, + `export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && (${escapedNode} -e 'require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")' 2>/dev/null || echo MISSING)` + ) + return probe.includes('ORCA-NATIVE-DEPS-OK') + } catch { + return false + } +} + +async function repairInstalledNativeDeps( + conn: SshConnection, + remoteDir: string, + platform: RelayPlatform +): Promise { + if (await hasRequiredNativeDeps(conn, remoteDir)) { + return + } + + console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`) + await acquireInstallLock(conn, remoteDir) + try { + // Why: older complete relay dirs were created before @parcel/watcher was + // installed. Re-probe under the lock so only one reconnect mutates the dir. + if (!(await hasRequiredNativeDeps(conn, remoteDir))) { + await installNativeDeps(conn, remoteDir, platform) + await finalizeInstall(conn, remoteDir) + } else { + await abandonInstall(conn, remoteDir) + } + } catch (err) { + await abandonInstall(conn, remoteDir) + throw err + } +} + +// Why: node-pty and @parcel/watcher are native addons that can't be bundled by +// esbuild. They must be installed on the remote host against its Node.js version +// and OS so dynamic imports/require calls resolve from the relay dir. +// +// TODO(#1693): VS Code ships per-platform tarballs with node-pty pre-built +// from CI and skips `npm install` on the remote entirely. That approach +// eliminates the whole class of bugs around npm/compiler/network failures +// on the remote. Worth doing once we're past the immediate fix. +async function installNativeDeps( + conn: SshConnection, + remoteDir: string, + platform: RelayPlatform +): Promise { const nodePath = await resolveRemoteNodePath(conn) // Why: node's bin directory must be in PATH for npm's child processes. // npm install runs node-pty's prebuild script (`node scripts/prebuild.js`) @@ -229,25 +287,80 @@ async function installNativeDeps(conn: SshConnection, remoteDir: string): Promis const nodeBinDir = nodePath.replace(/\/node$/, '') const escapedDir = shellEscape(remoteDir) const escapedBinDir = shellEscape(nodeBinDir) + const escapedNode = shellEscape(nodePath) + + // npm init -y rejects '+' in derived package names (content-hashed dir + // names like relay-0.1.0+abc123). Bypass it with a fixed minimal + // package.json. type:commonjs pins module resolution against Node default + // flips or a remote ~/.npmrc setting type=module. + const pkgJson = `${JSON.stringify({ + name: 'orca-relay', + version: '1.0.0', + private: true, + type: 'commonjs', + dependencies: Object.fromEntries(RELAY_NATIVE_DEPS.map((name) => [name, '*'])) + })}\n` + const sftpPkg = await conn.sftp() + try { + await new Promise((resolve, reject) => { + const ws = sftpPkg.createWriteStream(`${remoteDir}/package.json`) + // .once: a session 'error' arriving after we've already resolved/rejected + // would otherwise become an unhandled error and crash main. + sftpPkg.once('error', reject) + ws.once('close', resolve) + ws.once('error', reject) + ws.end(pkgJson) + }) + } finally { + sftpPkg.end() + } try { + const installArgs = RELAY_NATIVE_DEPS.map((dep) => shellEscape(dep)).join(' ') await execCommand( conn, - `export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && npm init -y --silent 2>/dev/null && npm install node-pty 2>&1` - ) - // Why: SFTP uploads preserve file content but not Unix execute bits. - // node-pty ships a prebuilt `spawn-helper` binary that must be executable - // for posix_spawnp to fork the PTY process. - await execCommand( - conn, - `find ${shellEscape(`${remoteDir}/node_modules/node-pty/prebuilds`)} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true` + `export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && npm install ${installArgs} 2>&1` ) } catch (err) { - // Why: node-pty install can fail if build tools (python, make, g++) are - // missing on the remote. Log the error but don't block relay startup — - // the relay will degrade gracefully (pty.spawn returns an error). - console.warn('[ssh-relay] Failed to install node-pty:', (err as Error).message) + // Don't write .install-complete on hard fail; reconnect retries on a + // partial install. Greppable token so user bug reports paste something + // searchable. + const msg = (err as Error).message + console.warn( + `[ssh-relay][NATIVE-DEPS-INSTALL-FAIL] npm install native deps failed at ${remoteDir} (${platform}): ${msg}` + ) + throw err } + + // SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild + // must be +x for posix_spawnp. + await execCommand( + conn, + `find ${shellEscape(`${remoteDir}/node_modules/node-pty/prebuilds`)} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true` + ) + + // node -e require() catches unloadable installs (wrong arch, missing + // prebuild, broken native binding) that test -d cannot. Stderr → file + // so .bashrc noise can't pollute the sentinel match; preserved for the + // [NPTY-MISSING] breadcrumb. MISSING is non-fatal by design — see + // docs/ssh-relay-versioned-install-dirs.md (relay still serves + // fs/git/preflight; only pty.spawn fails at runtime). + const PROBE_OK = 'ORCA-NPTY-PROBE-OK' + const stderrFile = `${remoteDir}/.npty-probe.stderr` + const escapedStderr = shellEscape(stderrFile) + const probeOutput = await execCommand( + conn, + `export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && (${escapedNode} -e 'require("node-pty"); console.log(process.argv[1])' ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` + ) + if (!probeOutput.includes(PROBE_OK)) { + const remoteStderr = await execCommand(conn, `cat ${escapedStderr} 2>/dev/null; true`).catch( + () => '' + ) + console.warn( + `[ssh-relay][NPTY-MISSING] node-pty installed but require() failed at ${remoteDir} (${platform}). stdout=${probeOutput.trim().slice(-200)} stderr=${remoteStderr.trim().slice(-500)}` + ) + } + await execCommand(conn, `rm -f ${escapedStderr} 2>/dev/null; true`).catch(() => {}) } function getLocalRelayPath(platform: RelayPlatform): string | null { @@ -345,7 +458,9 @@ async function launchRelay( const launchCmd = `cd ${escapedDir} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)} > ${shellEscape(logFile)} 2>&1 {}) + launchChannel.on('error', () => {}) launchChannel.stderr.on('data', () => {}) + launchChannel.stderr.on('error', () => {}) // Why: the shell exits quickly (nohup ... &), but the SSH channel stays // open until all child fds close. Explicitly closing it after the poll // loop prevents channel accumulation across relay restarts, which would diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts new file mode 100644 index 00000000000..cb4c6b6ec56 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -0,0 +1,515 @@ +/* eslint-disable max-lines -- Why: pinning every layer that should have + caught the original "node-pty not available" bug (chained shell, package + ordering, probe shape, channel-failure surfacing, .bashrc-noise immunity, + platform-tagged logs) requires keeping these scenarios in one file so the + shared mock connection and exec-response fixture stay aligned. */ +// Why: regression coverage for the install-probe contract. The original +// "node-pty is not available" bug shipped because every layer that should +// have caught it (chained shell, swallowing catch, dir-only probe) was +// silent. Tests below pin the parts that, individually, would have caught +// it. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + execCommand: vi.fn(), + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { + acquireInstallLock, + abandonInstall, + finalizeInstall, + isRelayAlreadyInstalled +} from './ssh-relay-versioned-install' +import type { SshConnection } from './ssh-connection' + +type SftpWriteCapture = { + paths: string[] + contents: Record + // Number of execCommand calls observed at the moment ws.end() ran for each + // captured path. Used to pin "package.json was written before npm install". + execCallCountAtWrite: Record +} + +function makeMockConnection(capture: SftpWriteCapture): SshConnection { + const sftpCreate = (): unknown => ({ + mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), + on: vi.fn(), + once: vi.fn(), + createWriteStream: vi.fn().mockImplementation((path: string) => { + capture.paths.push(path) + let buf = '' + let closeCb: (() => void) | undefined + const stub = { + on: vi.fn((event: string, cb: () => void) => { + if (event === 'close') { + closeCb = cb + } + }), + end: vi.fn((data?: string) => { + if (typeof data === 'string') { + buf += data + } + capture.contents[path] = buf + capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length + if (closeCb) { + setTimeout(closeCb, 0) + } + }) + } + // Why: production code uses ws.once('close', ...). The 'once' wrapper + // delegates to the same handler-table as 'on' for the test mock. + return Object.assign(stub, { once: stub.on }) + }), + end: vi.fn() + }) + return { + exec: vi.fn().mockResolvedValue({ + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }), + sftp: vi.fn().mockImplementation(() => Promise.resolve(sftpCreate())) + } as unknown as SshConnection +} + +type ExecResponse = string | { reject: string } + +// Exec call order under our mocks (deploy happy path): +// 1: uname 2: $HOME 3: mkdir remoteDir (uploadRelay) +// 4: chmod +x node 5: npm install 6: chmod prebuilds +// 7: probe (cd && node -e require) +// [8: cat stderr — only when probe stdout is MISSING (graceful path)] +// 8 or 9: rm probe-stderr (best-effort cleanup; runs whenever probe resolved) +// next: socket DEAD next: socket READY +// +// When the probe rejects (SSH channel close or cd-failure when the install +// dir vanished), the catch path skips both stderr-capture and the rm. +function makeExecResponses(opts: { + npmInstall: 'ok' | { reject: string } + // 'ok' : probe resolves with the sentinel; rm runs once + // 'missing' : probe resolves with 'MISSING'; cat stderr + rm both run + // 'dir-gone': probe rejects (cd-failure), exec rejects directly + // { reject }: probe rejects with custom error (e.g. SSH channel) + probe: 'ok' | 'missing' | 'dir-gone' | { reject: string } + // Override probe stdout for shell-noise pressure tests. If set, replaces + // the load-test stdout entirely (useful for testing pollution prefixes). + probeStdoutOverride?: string +}): ExecResponse[] { + const probeSlot: ExecResponse = + opts.probeStdoutOverride !== undefined + ? opts.probeStdoutOverride + : opts.probe === 'ok' + ? 'ORCA-NPTY-PROBE-OK\n' + : opts.probe === 'missing' + ? 'MISSING\n' // shell-level `|| echo MISSING` after require throw + : opts.probe === 'dir-gone' + ? { reject: 'cd: no such file or directory' } + : opts.probe + const slots: ExecResponse[] = [ + 'Linux x86_64', + '/home/u', + '', // mkdir remoteDir (uploadRelay) + '', // chmod +x node + opts.npmInstall === 'ok' ? '' : opts.npmInstall, + '', // chmod prebuilds + probeSlot + ] + // Cleanup execs only run when the probe resolved (not when it rejected). + const probeResolved = typeof probeSlot === 'string' + if (probeResolved) { + const probeOk = probeSlot.includes('ORCA-NPTY-PROBE-OK') + if (!probeOk) { + slots.push('') // cat stderr (graceful failure path captures detail) + } + slots.push('') // rm -f stderr (best-effort cleanup) + } + slots.push('DEAD', 'READY') + return slots +} + +describe('installNativeDeps (via deployAndLaunchRelay)', () => { + let warnSpy: ReturnType + const sftpCapture: SftpWriteCapture = { + paths: [], + contents: {}, + execCallCountAtWrite: {} + } + + beforeEach(() => { + vi.clearAllMocks() + // Tests that throw mid-deploy leave unconsumed `mockResolvedValueOnce` + // entries queued. Without resetting, the next test's first await consumes + // a leaked response. clearAllMocks doesn't drop the queue (it only clears + // .mock.calls), so we explicitly mockReset. + vi.mocked(execCommand).mockReset() + sftpCapture.paths.length = 0 + for (const k of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[k] + } + for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[k] + } + // Re-prime: factory mockReturnValue / mockResolvedValue survive + // clearAllMocks, so this is just defense-in-depth in case a test does its + // own resetAllMocks. + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + function feed(execResponses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const r of execResponses) { + if (typeof r === 'string') { + mockExec.mockResolvedValueOnce(r) + } else { + mockExec.mockRejectedValueOnce(new Error(r.reject)) + } + } + } + + it('writes a hardcoded package.json BEFORE running npm install', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const pkgPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) + expect(pkgPath, 'package.json must be written via SFTP').toBeTruthy() + + const written = sftpCapture.contents[pkgPath as string] + expect(written).toBeTruthy() + const parsed = JSON.parse(written) as Record + expect(parsed.name).toBe('orca-relay') + expect(parsed.version).toBe('1.0.0') + expect(parsed.private).toBe(true) + // Why: pin commonjs so a future Node default flip doesn't silently + // break `require('node-pty')`. + expect(parsed.type).toBe('commonjs') + expect(parsed.dependencies).toEqual({ '@parcel/watcher': '*', 'node-pty': '*' }) + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const npmInstallIdx = execCalls.findIndex( + (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') + ) + expect(npmInstallIdx).toBeGreaterThanOrEqual(0) + // Pin actual ordering: number of execCommand calls observed at the moment + // ws.end() ran for package.json must be < the index of `npm install`. + // Catches a future refactor that fires SFTP-write and npm install via + // Promise.all (where the final-state assertions above would still pass). + const writeObservedAt = sftpCapture.execCallCountAtWrite[pkgPath as string] + expect(writeObservedAt).toBeLessThanOrEqual(npmInstallIdx) + }) + + it('propagates a hard `npm install` failure so the deploy aborts before finalizeInstall', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: { reject: 'npm ERR! E404 Not Found node-pty' }, + probe: 'ok' + }) + ) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/npm ERR/) + + // The crucial regression: `.install-complete` must NOT have been written. + // Previously the catch swallowed the throw and finalizeInstall ran anyway. + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NATIVE-DEPS-INSTALL-FAIL]'))).toBe(true) + }) + + it('warns clearly when node-pty installs but require() fails (built-but-unloadable)', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing' })) + + await deployAndLaunchRelay(conn) + + // Probe failure is non-fatal by design (see docs/ssh-relay-versioned- + // install-dirs.md): relay still serves fs/git/preflight, only pty.spawn + // fails at runtime. Throwing here would loop reconnects forever on + // hosts where node-pty truly cannot build (Alpine without compiler, + // glibc too old). + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) + + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + }) + + it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: 'ok', + probe: { reject: 'SSH channel closed unexpectedly' } + }) + ) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel/) + + // Pin that the rejection actually came from the PROBE call (not some + // earlier/later exec). Drift in slot ordering would otherwise let this + // test pass while exercising a different failure path. + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const probeCallIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) + const npmInstallIdx = execCalls.findIndex( + (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') + ) + expect(probeCallIdx, 'probe must have been invoked').toBeGreaterThanOrEqual(0) + // Probe must come strictly AFTER `npm install` — otherwise we'd be + // probing into an empty install dir and this whole failure mode + // wouldn't represent the real-world race. + expect(probeCallIdx).toBeGreaterThan(npmInstallIdx) + + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + // Channel failure must NOT be conflated with "node-pty missing" or with + // "npm install failed". + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NATIVE-DEPS-INSTALL-FAIL]'))).toBe( + false + ) + + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + // Lock must be released so a future reconnect can retry. + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + }) + + it('throws (rather than warns MISSING) when the install dir vanishes between npm install and probe', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'dir-gone' })) + + // The probe shape `cd ${dir} && (node -e ... || echo MISSING)` short- + // circuits on cd-failure (`&&`), so the whole exec rejects rather than + // resolving with the MISSING sentinel. Conflating "dir vanished" with + // "node-pty missing" would mark the version installed and strand the + // user in degraded mode forever. + await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/cd:/) + + // Pin that the rejection came from the probe slot specifically, not + // some earlier exec — otherwise a future refactor could move probe + // before npm install and this test would still pass for the wrong + // reason. + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const probeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) + const npmInstallIdx = execCalls.findIndex( + (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') + ) + expect(probeIdx).toBeGreaterThan(npmInstallIdx) + + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + }) + + it('uses `node -e require()` rather than `test -d` so unloadable installs are caught', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const probeCmds = vi + .mocked(execCommand) + .mock.calls.map(([, c]) => c) + .filter((c) => c.includes(`require("node-pty")`)) + + // Why: the probe shape must invoke the deployed node binary against + // require('node-pty'). A weaker probe (test -d) could pass even when + // the native binding load is broken. + expect(probeCmds.length).toBeGreaterThan(0) + expect(probeCmds[0]).toMatch(/node['"]?\s+-e/) + + // Pin the full installNativeDeps exec sequence: npm install → chmod + // prebuilds → probe. A refactor that moves chmod-prebuilds after the + // probe would silently break spawn-helper bits; one that probes before + // npm install would test an empty dir. + const all = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const npmIdx = all.findIndex( + (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') + ) + const chmodPrebuildsIdx = all.findIndex( + (c) => c.includes('spawn-helper') && c.includes('chmod +x') + ) + const probeIdx = all.findIndex((c) => c.includes('require("node-pty")')) + expect(npmIdx).toBeGreaterThanOrEqual(0) + expect(chmodPrebuildsIdx).toBeGreaterThan(npmIdx) + expect(probeIdx).toBeGreaterThan(chmodPrebuildsIdx) + + // Happy path: finalize exactly once, abandon never. + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + }) + + it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => { + const conn = makeMockConnection(sftpCapture) + // Some remotes have customized .bashrc that prints to stdout on every + // non-interactive shell exec (corporate MOTD, NVM/conda init banners). + // Production uses .includes(PROBE_OK) with stderr redirected to a file, + // so noise on stdout BEFORE the sentinel must still resolve to OK. + feed( + makeExecResponses({ + npmInstall: 'ok', + probe: 'ok', + probeStdoutOverride: 'Welcome to Acme Corp\nLast login: ...\nORCA-NPTY-PROBE-OK\n' + }) + ) + + await deployAndLaunchRelay(conn) + + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + + it('detects MISSING even when the shell prepends noise before the MISSING token', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: 'ok', + probe: 'missing', + probeStdoutOverride: '(node:1234) [DEP0040] DeprecationWarning: ...\nMISSING\n' + }) + ) + + await deployAndLaunchRelay(conn) + + // Absence of PROBE_OK is what triggers the warn, regardless of what + // appears around it. finalize still runs (degraded-mode by design). + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + }) + + it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => { + // Platform tuple lets bug reports be triaged for prebuild availability + // without asking the user to dig out their arch. + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing' })) + await deployAndLaunchRelay(conn) + const missingMsgs = warnSpy.mock.calls + .map((args) => String(args[0] ?? '')) + .filter((m) => m.includes('[ssh-relay][NPTY-MISSING]')) + expect(missingMsgs.length).toBeGreaterThan(0) + expect(missingMsgs[0]).toContain('linux-x64') + }) + + it('writes an idempotent package.json (same bytes on every install)', async () => { + // First install run. + const conn1 = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + await deployAndLaunchRelay(conn1) + const firstPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string + const first = sftpCapture.contents[firstPath] + + // Reset capture, run again as if it were a fresh install of the same dir. + sftpCapture.paths.length = 0 + for (const k of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[k] + } + for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[k] + } + vi.mocked(execCommand).mockReset() + + const conn2 = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + await deployAndLaunchRelay(conn2) + const secondPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string + const second = sftpCapture.contents[secondPath] + + expect(second).toBe(first) + }) + + it('repairs an existing complete relay dir that is missing @parcel/watcher', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed([ + 'Linux x86_64', + '/home/u', + 'MISSING', // first native-deps probe before lock + 'MISSING', // re-probe after lock + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + expect(vi.mocked(acquireInstallLock)).toHaveBeenCalledTimes(1) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + expect( + execCalls.some( + (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') + ) + ).toBe(true) + }) + + it('does not mutate an existing relay dir when required native deps are present', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed(['Linux x86_64', '/home/u', 'ORCA-NATIVE-DEPS-OK', 'DEAD', 'READY']) + + await deployAndLaunchRelay(conn) + + expect(vi.mocked(acquireInstallLock)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts new file mode 100644 index 00000000000..0b56e275a7b --- /dev/null +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { SshRelaySession } from './ssh-relay-session' +import type { SshConnection } from './ssh-connection' +import type { Store } from '../persistence' +import type { SshPortForwardManager } from './ssh-port-forward' +import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import type { BrowserWindow } from 'electron' + +vi.mock('./ssh-relay-deploy', () => ({ + deployAndLaunchRelay: vi.fn() +})) + +vi.mock('./ssh-channel-multiplexer', () => { + return { + SshChannelMultiplexer: class MockSshChannelMultiplexer { + notify = vi.fn() + request = vi.fn().mockResolvedValue([]) + onNotification = vi.fn().mockReturnValue(() => {}) + onDispose = vi.fn().mockReturnValue(() => {}) + dispose = vi.fn() + isDisposed = vi.fn().mockReturnValue(false) + } + } +}) + +vi.mock('../providers/ssh-pty-provider', () => ({ + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + attach = vi.fn().mockResolvedValue(undefined) + dispose = vi.fn() + } +})) + +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + dispose = vi.fn() + } +})) + +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider {} +})) + +vi.mock('../ipc/pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn().mockReturnValue({ + dispose: vi.fn(), + attach: vi.fn().mockResolvedValue(undefined) + }), + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') + +function createMockDeps(): { + mockConn: SshConnection + mockStore: Store + mockPortForward: SshPortForwardManager + getMainWindow: () => BrowserWindow | null +} { + const mockConn = {} as SshConnection + const mockStore = { + getRepos: vi.fn().mockReturnValue([]) + } as unknown as Store + const mockPortForward = { + removeAllForwards: vi.fn() + } as unknown as SshPortForwardManager + const mockWindow = { + isDestroyed: (): boolean => false, + webContents: { send: vi.fn() } + } as unknown as BrowserWindow + const getMainWindow = vi.fn().mockReturnValue(mockWindow) as unknown as () => BrowserWindow | null + return { mockConn, mockStore, mockPortForward, getMainWindow } +} + +function mockDeploySuccess(): void { + const mockTransport = { + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + } + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: mockTransport, + platform: 'linux-x64' + }) +} + +describe('SshRelaySession terminal relay error (RelayVersionMismatchError)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeploySuccess() + }) + + it('fires onTerminalRelayError on initial establish() and rethrows', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + const onTerminal = vi.fn() + const onLost = vi.fn() + session.setOnTerminalRelayError(onTerminal) + session.setOnRelayLost(onLost) + + const mismatchErr = new RelayVersionMismatchError( + '0.1.0+aaa', + '0.1.0+bbb', + '[relay-connect] Handshake mismatch...' + ) + vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(mismatchErr) + + await expect(session.establish(mockConn)).rejects.toBe(mismatchErr) + expect(onTerminal).toHaveBeenCalledTimes(1) + expect(onTerminal).toHaveBeenCalledWith('target-1', mismatchErr) + expect(onLost).not.toHaveBeenCalled() + expect(session.getState()).toBe('idle') + }) + + it('does NOT fire onTerminalRelayError on a generic establish failure', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + const onTerminal = vi.fn() + session.setOnTerminalRelayError(onTerminal) + + vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(new Error('boom')) + + await expect(session.establish(mockConn)).rejects.toThrow('boom') + expect(onTerminal).not.toHaveBeenCalled() + expect(session.getState()).toBe('idle') + }) + + it('fires onTerminalRelayError on reconnect() when deploy throws RelayVersionMismatchError', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + const onTerminal = vi.fn() + session.setOnTerminalRelayError(onTerminal) + + await session.establish(mockConn) + expect(session.getState()).toBe('ready') + + const mismatchErr = new RelayVersionMismatchError('0.1.0+old', '0.1.0+new', '') + vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(mismatchErr) + + await session.reconnect(mockConn) + expect(onTerminal).toHaveBeenCalledTimes(1) + expect(onTerminal).toHaveBeenCalledWith('target-1', mismatchErr) + }) +}) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2a972db6af1..4401053f777 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -10,6 +10,8 @@ import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import type { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { SshPtyProvider } from '../providers/ssh-pty-provider' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' @@ -47,6 +49,14 @@ export class SshRelaySession { // up, the onStateChange reconnect path never fires. This callback lets // ssh.ts wire up relay-level reconnect from outside the session. private _onRelayLost: ((targetId: string) => void) | null = null + // Why: a wire-handshake mismatch is terminal — the daemon and client are at + // different versions, no amount of backoff retry will reconcile them. This + // separate callback lets ssh.ts surface the failure to the user and skip + // the relay-lost backoff loop entirely. Distinct from _onRelayLost because + // _onRelayLost expects a recoverable transport drop. + private _onTerminalRelayError: + | ((targetId: string, err: RelayVersionMismatchError) => void) + | null = null private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null @@ -67,6 +77,10 @@ export class SshRelaySession { this._onRelayLost = cb } + setOnTerminalRelayError(cb: (targetId: string, err: RelayVersionMismatchError) => void): void { + this._onTerminalRelayError = cb + } + setOnReady(cb: (targetId: string) => void): void { this._onReady = cb } @@ -159,6 +173,20 @@ export class SshRelaySession { this.teardownProviders('shutdown') this._state = 'idle' } + // Why: a wire-handshake mismatch on the FIRST connect is also terminal + // — the deployed relay binary on disk does not match a still-running + // daemon (typically because a legacy daemon from before the + // versioned-dir change is still alive). Notify the terminal-error + // callback so ssh.ts surfaces an actionable message and the caller's + // catch path doesn't conflate this with a transient deploy failure. + // We still rethrow so doConnect's existing failure path runs (clean up + // the SSH connection); ssh.ts's handler is idempotent. + if (isRelayVersionMismatchError(err)) { + console.warn( + `[ssh-relay-session] Terminal relay version mismatch on initial connect for ${this.targetId}: ${err.message}` + ) + this._onTerminalRelayError?.(this.targetId, err) + } throw err } } @@ -294,6 +322,17 @@ export class SshRelaySession { if (this.abortController === abortController && !this.isDisposed()) { this.teardownProviders('connection_lost') } + // Why: a version-mismatch is terminal. Fire the typed callback so + // ssh.ts can surface a "please reconnect manually" notice and skip the + // relay-lost backoff loop entirely. We do NOT keep state at + // 'reconnecting' — there's no transient drop to recover from. + if (isRelayVersionMismatchError(err)) { + console.warn( + `[ssh-relay-session] Terminal relay version mismatch for ${this.targetId}: ${err.message}` + ) + this._onTerminalRelayError?.(this.targetId, err) + return + } // Why: stay in 'reconnecting' rather than reverting to 'ready', because // the provider stack is already torn down. The SSH connection manager // will fire another onStateChange when it reconnects again. @@ -390,9 +429,10 @@ export class SshRelaySession { unregisterSshGitProvider(this.targetId) } - // Why: the relay's RelayContext starts with rootsRegistered=false and rejects - // all FS operations until at least one root is registered. This must run - // after every relay deploy because each deploy creates a fresh RelayContext. + // Why: kept for back-compat with old relay binaries during the upgrade + // window — those still gate FS ops on registered roots. New relays no-op + // these notifications. Tracked for removal once the relay-version floor + // moves past the cutover (see docs/relay-fs-allowlist-removal.md). private async registerRelayRoots(mux: SshChannelMultiplexer): Promise { const remoteRepos = this.store.getRepos().filter((r) => r.connectionId === this.targetId) diff --git a/src/main/ssh/ssh-relay-version-mismatch-error.ts b/src/main/ssh/ssh-relay-version-mismatch-error.ts new file mode 100644 index 00000000000..66bc90c286a --- /dev/null +++ b/src/main/ssh/ssh-relay-version-mismatch-error.ts @@ -0,0 +1,34 @@ +// Why: a unique error class so callers (in particular the relay-lost retry +// loop in src/main/ipc/ssh.ts) can branch on `instanceof +// RelayVersionMismatchError` and treat the failure as terminal — i.e. skip +// the exponential-backoff retry and surface a user-visible "please reconnect +// manually" error. Any other transport failure remains transiently retryable. +// +// Trigger: the remote `--connect` process exits with code 42 after the +// daemon's wire-level handshake reports a version mismatch. See +// docs/ssh-relay-versioned-install-dirs.md. + +export class RelayVersionMismatchError extends Error { + readonly name = 'RelayVersionMismatchError' + + constructor( + readonly expected: string | undefined, + readonly got: string | undefined, + readonly stderr?: string + ) { + super( + `Remote relay version mismatch — expected=${expected ?? 'unknown'}, ` + + `daemon=${got ?? 'unknown'}. The remote daemon was launched against a different ` + + `relay binary than the local client expects. Please reconnect manually.` + ) + } +} + +export function isRelayVersionMismatchError(err: unknown): err is RelayVersionMismatchError { + return err instanceof RelayVersionMismatchError +} + +// Why: the remote --connect process uses this exit code to signal the wire +// handshake failed because of a version mismatch. The mapping daemon ⇄ exit +// code 42 lives in src/relay/relay-handshake.ts (EXIT_CODE_VERSION_MISMATCH). +export const RELAY_EXIT_CODE_VERSION_MISMATCH = 42 diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts new file mode 100644 index 00000000000..a7bae19e9cf --- /dev/null +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn() +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn() +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { existsSync, readFileSync } from 'fs' +import { + readLocalFullVersion, + computeRemoteRelayDir, + isRelayAlreadyInstalled, + acquireInstallLock, + finalizeInstall, + abandonInstall, + gcOldRelayVersions +} from './ssh-relay-versioned-install' +import { execCommand } from './ssh-relay-deploy-helpers' +import type { SshConnection } from './ssh-connection' + +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) +const mockExists = vi.mocked(existsSync) +const mockRead = vi.mocked(readFileSync) + +describe('readLocalFullVersion', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns trimmed contents of the .version file', () => { + mockExists.mockReturnValue(true) + mockRead.mockReturnValue('0.1.0+deadbeef\n') + expect(readLocalFullVersion('/local/relay')).toBe('0.1.0+deadbeef') + }) + + it('throws an actionable error when the .version file is missing', () => { + mockExists.mockReturnValue(false) + expect(() => readLocalFullVersion('/local/relay')).toThrow(/missing its version marker/) + }) + + it('throws when the .version file is empty', () => { + mockExists.mockReturnValue(true) + mockRead.mockReturnValue(' \n') + expect(() => readLocalFullVersion('/local/relay')).toThrow(/is empty/) + }) +}) + +describe('computeRemoteRelayDir', () => { + it('joins remoteHome with .orca-remote and the version-keyed dir name', () => { + expect(computeRemoteRelayDir('/home/u', '0.1.0+abc')).toBe( + '/home/u/.orca-remote/relay-0.1.0+abc' + ) + }) +}) + +describe('isRelayAlreadyInstalled', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns true only when the OK probe succeeds', async () => { + mockExec.mockResolvedValueOnce('OK') + expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(true) + }) + + it('returns false when the probe reports MISSING', async () => { + mockExec.mockResolvedValueOnce('MISSING') + expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) + }) + + it('returns false on exec error', async () => { + mockExec.mockRejectedValueOnce(new Error('boom')) + expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) + }) + + it('checks for relay.js AND .install-complete in addition to the dir', async () => { + mockExec.mockResolvedValueOnce('OK') + await isRelayAlreadyInstalled(conn, '/r') + const cmd = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(cmd).toContain('relay.js') + expect(cmd).toContain('.install-complete') + }) +}) + +describe('acquireInstallLock', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns when mkdir reports OK', async () => { + // 1st call: mkdir -p remoteRelayDir + // 2nd call: mkdir lockDir → OK + mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('OK') + await acquireInstallLock(conn, '/r') + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => { + vi.useFakeTimers() + try { + // Sequence: + // 1. mkdir -p (parent dir prep) + // 2. mkdir lockDir → BUSY (someone else holds it) + // 3. mkdir lockDir → BUSY again + // 4. mkdir lockDir → OK (concurrent installer released) + mockExec + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OK') + + const promise = acquireInstallLock(conn, '/r') + // Drive the polling loop: each iteration awaits a 1s timer. + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(1_000) + } + await promise + const cmds = mockExec.mock.calls.map(([, c]) => c) + const mkdirAttempts = cmds.filter((c) => c.includes('mkdir') && c.includes('.install-lock')) + expect(mkdirAttempts.length).toBeGreaterThanOrEqual(3) + } finally { + vi.useRealTimers() + } + }) + + it('steals a stale lock and retries with a reset timeout window', async () => { + vi.useFakeTimers({ now: 1_700_000_000_000 }) + try { + let mkdirCalls = 0 + mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.startsWith('mkdir -p')) { + return '' + } + if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { + mkdirCalls++ + return mkdirCalls > 200 ? 'OK' : 'BUSY' + } + if (cmd.includes('stat')) { + return `${Math.floor((Date.now() - 10 * 60 * 1000) / 1000)}\n` + } + if (cmd.startsWith('rm -rf')) { + mkdirCalls = 1000 + return '' + } + return '' + }) + + const promise = acquireInstallLock(conn, '/r') + // Drive through the full timeout (120s) so the stale-recovery branch + // fires, then drive a few more seconds for the post-recovery retry. + await vi.advanceTimersByTimeAsync(125_000) + await promise + + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds.some((c) => c.includes('rm -rf') && c.includes('.install-lock'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('throws if the timeout elapses and the lock is fresh', async () => { + vi.useFakeTimers({ now: 1_700_000_000_000 }) + try { + mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.startsWith('mkdir -p')) { + return '' + } + if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { + return 'BUSY' + } + if (cmd.includes('stat')) { + return `${Math.floor(Date.now() / 1000)}\n` + } + return '' + }) + + const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow(/not yet stale/i) + await vi.advanceTimersByTimeAsync(125_000) + await rejection + } finally { + vi.useRealTimers() + } + }) + + it('finalizeInstall writes .install-complete then removes the lock', async () => { + mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('') + await finalizeInstall(conn, '/r') + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds[0]).toContain('touch') + expect(cmds[0]).toContain('.install-complete') + expect(cmds[1]).toContain('rm -rf') + expect(cmds[1]).toContain('.install-lock') + }) + + it('abandonInstall removes the lock without writing the sentinel', async () => { + mockExec.mockResolvedValueOnce('') + await abandonInstall(conn, '/r') + const cmd = mockExec.mock.calls[0]?.[1] ?? '' + expect(cmd).toContain('rm -rf') + expect(cmd).toContain('.install-lock') + expect(cmd).not.toContain('.install-complete') + }) +}) + +describe('gcOldRelayVersions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('removes a sibling that is complete, unlocked, and has no live socket', async () => { + // ls listing + mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n') + // For sibling "aaa": LOCKED probe → OPEN, COMPLETE probe → COMPLETE, sock probe → empty (no ALIVE), then rm -rf + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(lastCmd).toContain('rm -rf') + expect(lastCmd).toContain('relay-0.1.0+aaa') + }) + + it('skips siblings that are missing .install-complete (mid-install or partial)', async () => { + mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + mockExec + .mockResolvedValueOnce('OPEN') // not locked + .mockResolvedValueOnce('PARTIAL') // missing .install-complete + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) + }) + + it('skips siblings whose .install-lock is held and fresh', async () => { + mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + mockExec.mockResolvedValueOnce('LOCKED') + // isLockStale: mtime ~now → not stale. + mockExec.mockResolvedValueOnce(`${Math.floor(Date.now() / 1000)}\n`) + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) + }) + + it('removes a sibling with a stale lock + .install-complete (rm-lock failed mid-finalize)', async () => { + mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + mockExec.mockResolvedValueOnce('LOCKED') + // isLockStale: mtime well in the past → stale. + const staleSec = Math.floor((Date.now() - 10 * 60 * 1000) / 1000) + mockExec.mockResolvedValueOnce(`${staleSec}\n`) + mockExec.mockResolvedValueOnce('COMPLETE') // .install-complete present + mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE + mockExec.mockResolvedValueOnce('') // rm -rf + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(lastCmd).toContain('rm -rf') + expect(lastCmd).toContain('relay-0.1.0+aaa') + }) + + it('GCs a legacy relay-v0.1.0 dir whose daemon is dead (no .install-complete required)', async () => { + mockExec.mockResolvedValueOnce('relay-v0.1.0\n') + mockExec.mockResolvedValueOnce('OPEN') // not locked + mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE (no completeProbe — legacy) + mockExec.mockResolvedValueOnce('') // rm -rf + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds.some((c) => c.includes('rm -rf') && c.includes('relay-v0.1.0'))).toBe(true) + // critically: no .install-complete probe on legacy dirs + expect(cmds.some((c) => c.includes('.install-complete'))).toBe(false) + }) + + it('keeps a legacy relay-v0.1.0 dir whose daemon is still serving', async () => { + mockExec.mockResolvedValueOnce('relay-v0.1.0\n') + mockExec.mockResolvedValueOnce('OPEN') + mockExec.mockResolvedValueOnce('ALIVE') // socket alive → keep + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) + }) + + it('skips siblings with a live relay-*.sock', async () => { + mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('ALIVE') + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) + }) + + it('does not consider the current dir as a GC candidate', async () => { + mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+aaa') + expect(mockExec.mock.calls.length).toBe(1) // only the listing + }) + + it('ignores entries that do not match the relay version dir regex (allowlist)', async () => { + mockExec.mockResolvedValueOnce('logs\nbackup\nrelay-0.1.0+aaa\n') + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + const cmds = mockExec.mock.calls.map(([, c]) => c) + const rmCmds = cmds.filter((c) => c.includes('rm -rf')) + expect(rmCmds).toHaveLength(1) + expect(rmCmds[0]).toContain('relay-0.1.0+aaa') + expect(rmCmds[0]).not.toContain('logs') + expect(rmCmds[0]).not.toContain('backup') + }) +}) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts new file mode 100644 index 00000000000..07aaa28c2d0 --- /dev/null +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -0,0 +1,340 @@ +// Versioned-install plumbing for the remote relay. +// +// Why this exists: the relay used to install into a single shared directory +// (~/.orca-remote/relay-v0.1.0) which the deploy step would overwrite in place +// on every cross-version push. A daemon already loaded into memory then served +// new clients off rewritten on-disk code, producing protocol drift and a +// reconnect loop. We now install each (RELAY_VERSION + content-hash) bundle +// into its own directory and never mutate it after the install finishes, +// matching VS Code's `~/.vscode-server/bin//` layout. +// +// See: docs/ssh-relay-versioned-install-dirs.md + +import { join } from 'path' +import { existsSync, readFileSync } from 'fs' +import type { SshConnection } from './ssh-connection' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { execCommand } from './ssh-relay-deploy-helpers' +import { shellEscape } from './ssh-connection-utils' + +// Why: the GC pass and the version-dir parser must agree on what counts as a +// relay install dir. Single source of truth for both. The pattern matches the +// new layout `relay-${RELAY_VERSION}+${hash}` and the legacy `relay-v${VERSION}` +// so the GC eventually drains the old layout once its daemons idle out. +const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/ + +// Why: legacy dirs from before `.install-complete` was introduced (i.e. the +// `relay-v0.1.0` shape with no content-hash suffix). They are missing the +// install-complete sentinel by definition and need a separate liveness-only +// GC check so they actually drain after the legacy daemon dies, instead of +// living on remote disks forever. +const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ + +const INSTALL_LOCK_NAME = '.install-lock' +const INSTALL_COMPLETE_NAME = '.install-complete' + +const INSTALL_LOCK_POLL_MS = 1_000 +const INSTALL_LOCK_TIMEOUT_MS = 120_000 +// Why: a stale lock dir from a crashed installer must be recoverable without +// user intervention. After the timeout we check the lock's mtime; if it's +// older than this window the previous installer is assumed dead and we steal +// the lock. 2 minutes is well above a normal `npm install node-pty` runtime +// (10–60s on slow hosts) so a slow concurrent installer is not falsely +// declared dead. +const INSTALL_LOCK_STALE_MS = 120_000 + +/** + * Read the local relay's content-hashed version (e.g. "0.1.0+0a5fe134d020") + * from `${localRelayDir}/.version`. Throws on missing/empty so the caller + * never silently falls back to a path where a daemon from a different code + * generation may already be running — that fallback is the failure mode the + * versioned-install design exists to prevent. + */ +export function readLocalFullVersion(localRelayDir: string): string { + const versionFile = join(localRelayDir, '.version') + if (!existsSync(versionFile)) { + throw new Error( + `Orca's local relay build is missing its version marker at ${versionFile}. ` + + `This usually indicates a packaging or build problem; reinstall Orca.` + ) + } + const v = readFileSync(versionFile, 'utf-8').trim() + if (!v) { + throw new Error( + `Orca's local relay version marker at ${versionFile} is empty. ` + + `This usually indicates a packaging or build problem; reinstall Orca.` + ) + } + return v +} + +/** + * Compute the absolute remote install directory for a given content-hashed + * version. The format is `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}`. + */ +export function computeRemoteRelayDir(remoteHome: string, fullVersion: string): string { + return `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}` +} + +/** + * Probe whether a fully-installed relay already exists at remoteRelayDir. + * + * "Fully installed" means: the directory exists, contains relay.js, AND + * contains the .install-complete sentinel written at the end of a successful + * install. A directory missing .install-complete is either mid-install (lock + * held) or a crashed-install partial — either way we re-run the deploy. + */ +export async function isRelayAlreadyInstalled( + conn: SshConnection, + remoteRelayDir: string +): Promise { + try { + const probe = await execCommand( + conn, + `test -d ${shellEscape(remoteRelayDir)} ` + + `&& test -f ${shellEscape(`${remoteRelayDir}/relay.js`)} ` + + `&& test -f ${shellEscape(`${remoteRelayDir}/${INSTALL_COMPLETE_NAME}`)} ` + + `&& echo OK || echo MISSING` + ) + return probe.trim() === 'OK' + } catch { + return false + } +} + +/** + * Acquire the per-version install lock via atomic `mkdir`. Returns when the + * caller owns the lock; throws if the lock could not be acquired within + * INSTALL_LOCK_TIMEOUT_MS even after one stale-lock recovery attempt. + * + * Why mkdir: POSIX `mkdir` is atomic and fails with EEXIST if the dir already + * exists, giving us a free mutex. A second concurrent caller polls and + * eventually either acquires the lock or steals it after the stale window. + */ +export async function acquireInstallLock( + conn: SshConnection, + remoteRelayDir: string +): Promise { + const lockDir = `${remoteRelayDir}/${INSTALL_LOCK_NAME}` + // Why: the parent dir may not exist yet on a first install. mkdir -p is + // safe to run multiple times — it's a no-op if the dir already exists. + await execCommand(conn, `mkdir -p ${shellEscape(remoteRelayDir)}`) + + let start = Date.now() + let recoveredOnce = false + while (true) { + try { + const result = await execCommand( + conn, + `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` + ) + if (result.trim().endsWith('OK')) { + return + } + } catch { + /* mkdir failed with non-zero — fall through to BUSY treatment */ + } + if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { + if (recoveredOnce) { + throw new Error( + `Could not acquire relay install lock at ${lockDir} after ${ + INSTALL_LOCK_TIMEOUT_MS / 1000 + }s; another install is in progress or the lock is wedged.` + ) + } + // Stale-lock recovery: if the lock dir's mtime is older than the stale + // window, the previous installer crashed. Steal it and retry once, + // resetting the timeout window so a single post-recovery race doesn't + // immediately exhaust the budget. + const ageOk = await isLockStale(conn, lockDir) + if (ageOk) { + console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) + await execCommand(conn, `rm -rf ${shellEscape(lockDir)}`).catch(() => {}) + recoveredOnce = true + start = Date.now() + continue + } + throw new Error( + `Could not acquire relay install lock at ${lockDir} after ${ + INSTALL_LOCK_TIMEOUT_MS / 1000 + }s and the lock is not yet stale.` + ) + } + await new Promise((r) => setTimeout(r, INSTALL_LOCK_POLL_MS)) + } +} + +async function isLockStale(conn: SshConnection, lockDir: string): Promise { + try { + // Why: `stat` flags differ between GNU coreutils (Linux) and BSD (macOS). + // We try GNU first, then BSD; both produce a Unix epoch in seconds on + // stdout. If both fail we conservatively treat the lock as not stale. + const out = await execCommand( + conn, + `stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo` + ) + const mtimeSec = parseInt(out.trim(), 10) + if (!Number.isFinite(mtimeSec)) { + return false + } + const ageMs = Date.now() - mtimeSec * 1000 + return ageMs > INSTALL_LOCK_STALE_MS + } catch { + return false + } +} + +/** + * Mark the install as complete and release the lock. Sentinel ordering is: + * write `.install-complete` FIRST, then remove `.install-lock`. This ensures + * a sibling dir is never observed by GC as "complete but locked", which + * would lead GC to skip a recoverable dir indefinitely. + */ +export async function finalizeInstall(conn: SshConnection, remoteRelayDir: string): Promise { + const sentinel = `${remoteRelayDir}/${INSTALL_COMPLETE_NAME}` + const lock = `${remoteRelayDir}/${INSTALL_LOCK_NAME}` + await execCommand(conn, `touch ${shellEscape(sentinel)}`) + await execCommand(conn, `rm -rf ${shellEscape(lock)}`).catch(() => {}) +} + +/** + * Release the install lock without writing the completion sentinel. Called + * from the failure path so the dir remains a recoverable partial that the + * next deploy detects (alreadyInstalled=false) and re-runs upload+install. + */ +export async function abandonInstall(conn: SshConnection, remoteRelayDir: string): Promise { + const lock = `${remoteRelayDir}/${INSTALL_LOCK_NAME}` + await execCommand(conn, `rm -rf ${shellEscape(lock)}`).catch(() => {}) +} + +/** + * Garbage-collect old version directories. Removes a sibling dir under + * `${remoteHome}/${RELAY_REMOTE_DIR}/` only if ALL of: + * + * - it matches the relay-version-dir regex (allowlist) + * - it is NOT the current version dir + * - it has no live `relay-*.sock` (pgrep + connectability probe) + * - it contains `.install-complete` (a fully-installed dir, not a partial) + * - it does NOT contain `.install-lock` (no in-progress install) + * + * Best-effort: any error is logged and swallowed; GC must never block the + * user from connecting. + */ +export async function gcOldRelayVersions( + conn: SshConnection, + remoteHome: string, + currentDirAbsPath: string +): Promise { + const baseDir = `${remoteHome}/${RELAY_REMOTE_DIR}` + const currentDirName = currentDirAbsPath.split('/').filter(Boolean).pop() ?? '' + let listing: string + try { + listing = await execCommand(conn, `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true`) + } catch { + return + } + const candidates = listing + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .filter((name) => RELAY_VERSION_DIR_REGEX.test(name)) + .filter((name) => name !== currentDirName) + + if (candidates.length === 0) { + return + } + + const removed: string[] = [] + const kept: string[] = [] + for (const name of candidates) { + const dir = `${baseDir}/${name}` + try { + const safe = await isCandidateSafeToRemove(conn, dir, name) + if (!safe) { + kept.push(name) + continue + } + await execCommand(conn, `rm -rf ${shellEscape(dir)}`) + removed.push(name) + } catch (err) { + console.warn( + `[ssh-relay] GC failed for ${dir}: ${err instanceof Error ? err.message : String(err)}` + ) + kept.push(name) + } + } + + if (removed.length > 0) { + const keptSuffix = kept.length > 0 ? ` (kept: ${kept.join(', ')})` : '' + console.log( + `[ssh-relay] GC: removed ${removed.length} stale version dir(s): ${removed.join(', ')}${keptSuffix}` + ) + } +} + +async function isCandidateSafeToRemove( + conn: SshConnection, + dir: string, + name: string +): Promise { + const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name) + + const lockProbe = await execCommand( + conn, + `test -d ${shellEscape(`${dir}/${INSTALL_LOCK_NAME}`)} && echo LOCKED || echo OPEN` + ).catch(() => 'OPEN') + const locked = lockProbe.trim() === 'LOCKED' + + if (locked) { + // Why: a locked dir is normally unsafe to remove — but a STALE lock + // (mtime older than INSTALL_LOCK_STALE_MS) means the previous installer + // crashed and is never coming back. If the dir also has the + // .install-complete sentinel (touch succeeded but the rm-lock at the + // end of finalizeInstall failed), removing the dir is safe — no + // installer is racing us, and the daemon (if any) keeps running off + // its already-loaded code regardless of disk state. + const lockDir = `${dir}/${INSTALL_LOCK_NAME}` + if (!(await isLockStale(conn, lockDir))) { + return false + } + process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`) + } + + // Legacy dirs (relay-v0.1.0) predate .install-complete. Skip the sentinel + // check for them and rely solely on the live-socket probe — that's the + // only signal we have that a legacy daemon is still serving clients. + if (!isLegacy) { + const completeProbe = await execCommand( + conn, + `test -f ${shellEscape(`${dir}/${INSTALL_COMPLETE_NAME}`)} && echo COMPLETE || echo PARTIAL` + ).catch(() => 'PARTIAL') + if (completeProbe.trim() !== 'COMPLETE') { + // Crashed-install partial; leave for the next deploy to recover. + return false + } + } + + const sockAlive = await hasLiveRelaySocket(conn, dir) + if (sockAlive) { + return false + } + return true +} + +async function hasLiveRelaySocket(conn: SshConnection, dir: string): Promise { + try { + // Why: `ls -1 dir/relay-*.sock 2>/dev/null` lists socket files. For each, + // we test -S to confirm it's a socket inode. We do NOT attempt to open + // the socket here — `test -S` is sufficient for the GC decision and a + // connect-and-close probe would race with a daemon that's about to idle. + const out = await execCommand( + conn, + `for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` + + `[ -S "$f" ] && echo ALIVE && break; ` + + `done; true` + ) + return out.includes('ALIVE') + } catch { + return false + } +} diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index 9006b93fde4..32a133a44a4 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -2,9 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { _resetHydrateShellPathCache, hydrateShellPath, - mergePathSegments + mergePathSegments, + type HydrationResult } from './hydrate-shell-path' +type HydrationSpawner = (shell: string) => Promise + describe('hydrateShellPath', () => { const originalPath = process.env.PATH @@ -28,7 +31,8 @@ describe('hydrateShellPath', () => { capturedShell = shell return { segments: ['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'], - ok: true + ok: true, + failureReason: 'none' } } }) @@ -36,13 +40,14 @@ describe('hydrateShellPath', () => { expect(capturedShell).toBe('/bin/zsh') expect(result.ok).toBe(true) expect(result.segments).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin']) + expect(result.failureReason).toBe('none') }) it('caches the hydration result so repeated calls do not re-spawn', async () => { let spawnCount = 0 - const spawner = async (): Promise<{ segments: string[]; ok: boolean }> => { + const spawner: HydrationSpawner = async () => { spawnCount += 1 - return { segments: ['/a'], ok: true } + return { segments: ['/a'], ok: true, failureReason: 'none' } } await hydrateShellPath({ shellOverride: '/bin/zsh', spawner }) @@ -54,9 +59,9 @@ describe('hydrateShellPath', () => { it('re-spawns when force:true is passed — matches the Refresh button contract', async () => { let spawnCount = 0 - const spawner = async (): Promise<{ segments: string[]; ok: boolean }> => { + const spawner: HydrationSpawner = async () => { spawnCount += 1 - return { segments: ['/a'], ok: true } + return { segments: ['/a'], ok: true, failureReason: 'none' } } await hydrateShellPath({ shellOverride: '/bin/zsh', spawner }) @@ -65,7 +70,7 @@ describe('hydrateShellPath', () => { expect(spawnCount).toBe(2) }) - it('returns ok:false when no shell is available (Windows path)', async () => { + it('returns failureReason:no_shell when no shell is available (Windows path)', async () => { const result = await hydrateShellPath({ shellOverride: null, spawner: async () => { @@ -73,7 +78,36 @@ describe('hydrateShellPath', () => { } }) - expect(result).toEqual({ segments: [], ok: false }) + expect(result).toEqual({ segments: [], ok: false, failureReason: 'no_shell' }) + }) + + // Why: each failure mode tagged independently so dashboards can pick the + // right fix (lengthen timeout vs investigate shell-invocation strategy vs + // surface a UX error). Spawner override stands in for the four resolve + // sites — the actual classification happens inside `spawnShellAndReadPath`, + // covered by the existing real-shell smoke surface. + it('propagates failureReason:timeout from the spawner', async () => { + const result = await hydrateShellPath({ + shellOverride: '/bin/zsh', + spawner: async () => ({ segments: [], ok: false, failureReason: 'timeout' }) + }) + expect(result).toEqual({ segments: [], ok: false, failureReason: 'timeout' }) + }) + + it('propagates failureReason:spawn_error from the spawner', async () => { + const result = await hydrateShellPath({ + shellOverride: '/bin/zsh', + spawner: async () => ({ segments: [], ok: false, failureReason: 'spawn_error' }) + }) + expect(result).toEqual({ segments: [], ok: false, failureReason: 'spawn_error' }) + }) + + it('propagates failureReason:empty_path from the spawner', async () => { + const result = await hydrateShellPath({ + shellOverride: '/bin/zsh', + spawner: async () => ({ segments: [], ok: false, failureReason: 'empty_path' }) + }) + expect(result).toEqual({ segments: [], ok: false, failureReason: 'empty_path' }) }) }) diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 75a7f014e24..275d5cb9265 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -1,5 +1,6 @@ import { spawn } from 'child_process' import { delimiter } from 'path' +import type { ShellHydrationFailureReason } from '../../shared/types' // Why: GUI-launched Electron on macOS/Linux inherits a minimal PATH from launchd // that does not include dirs appended by the user's shell rc files (~/.zshrc, @@ -20,12 +21,17 @@ const SPAWN_TIMEOUT_MS = 5000 // files print banners or set colored prompts. Strip them before parsing. const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g // eslint-disable-line no-control-regex -type HydrationResult = { - /** PATH segments extracted from the login shell, in order, de-duplicated. */ - segments: string[] - /** True when the shell spawn succeeded and returned a non-empty PATH. */ - ok: boolean -} +// Why: the discriminator lets telemetry classify *why* hydration failed, not +// just whether it did. Five resolve sites in this file each tag the result +// with the right reason. The shared alias keeps the enum in lockstep with the +// telemetry schema (compile-time guard in telemetry-events.ts). +export type HydrationResult = + | { ok: true; segments: string[]; failureReason: 'none' } + | { + ok: false + segments: [] + failureReason: Exclude + } let cached: Promise | null = null @@ -104,7 +110,7 @@ function spawnShellAndReadPath(shell: string): Promise { } catch { // ignore } - resolve({ segments: [], ok: false }) + resolve({ segments: [], ok: false, failureReason: 'timeout' }) }, SPAWN_TIMEOUT_MS) child.stdout.on('data', (chunk: Buffer) => { @@ -117,7 +123,7 @@ function spawnShellAndReadPath(shell: string): Promise { } finished = true clearTimeout(timer) - resolve({ segments: [], ok: false }) + resolve({ segments: [], ok: false, failureReason: 'spawn_error' }) }) child.on('close', () => { @@ -127,7 +133,11 @@ function spawnShellAndReadPath(shell: string): Promise { finished = true clearTimeout(timer) const segments = parseCapturedPath(stdout) - resolve({ segments, ok: segments.length > 0 }) + if (segments.length === 0) { + resolve({ segments: [], ok: false, failureReason: 'empty_path' }) + return + } + resolve({ segments, ok: true, failureReason: 'none' }) }) }) } @@ -154,7 +164,7 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise { + it('classifies ENOENT as binary_not_found', () => { + expect(classifyError(new Error('spawn zsh ENOENT'))).toEqual({ + error_class: 'binary_not_found' + }) + expect(classifyError(Object.assign(new Error('missing'), { code: 'ENOENT' }))).toEqual({ + error_class: 'binary_not_found' + }) + }) + + it('keeps shell-output-shaped not-found messages unknown', () => { + expect(classifyError(new Error('command not found: claude'))).toEqual({ + error_class: 'unknown' + }) + expect(classifyError(new Error('workspace not found'))).toEqual({ + error_class: 'unknown' + }) + expect( + classifyError( + Object.assign(new Error('socket missing'), { code: 'ENOENT', syscall: 'connect' }) + ) + ).toEqual({ error_class: 'unknown' }) + }) + + it('returns unknown for null/undefined input', () => { + expect(classifyError(null)).toEqual({ error_class: 'unknown' }) + expect(classifyError(undefined)).toEqual({ error_class: 'unknown' }) + }) + + it('returns unknown for an unrecognized message', () => { + expect(classifyError(new Error('some unique failure mode we do not know'))).toEqual({ + error_class: 'unknown' + }) + }) + + it('returns unknown for non-Error throws', () => { + expect(classifyError('a bare string')).toEqual({ error_class: 'unknown' }) + expect(classifyError({ random: 'object' })).toEqual({ error_class: 'unknown' }) + }) +}) diff --git a/src/main/telemetry/classify-error.ts b/src/main/telemetry/classify-error.ts new file mode 100644 index 00000000000..06b5700dbe4 --- /dev/null +++ b/src/main/telemetry/classify-error.ts @@ -0,0 +1,44 @@ +// Best-effort classifier from a thrown value to the closed `error_class` +// enum on `agent_error`. The wire never carries a raw error message or stack +// — every transmitted error is bucketed into one of the two enum members +// (`binary_not_found` for ENOENT-shaped failures, `unknown` for everything +// else). A non-zero `unknown` slice on the dashboard is the trigger to add a +// new enum value alongside the call site that would emit it. + +import type { ErrorClass } from '../../shared/telemetry-events' + +export type ClassifiedError = { + error_class: ErrorClass +} + +export function classifyError(err: unknown): ClassifiedError { + if (err === null || err === undefined) { + return { error_class: 'unknown' } + } + + const code = + typeof err === 'object' && err !== null && 'code' in err + ? (err as { code?: unknown }).code + : undefined + const syscall = + typeof err === 'object' && err !== null && 'syscall' in err + ? (err as { syscall?: unknown }).syscall + : undefined + const message = + typeof err === 'object' && err !== null && 'message' in err + ? (err as { message?: unknown }).message + : undefined + + if ( + code === 'ENOENT' && + (typeof syscall !== 'string' || syscall.toLowerCase().startsWith('spawn')) + ) { + return { error_class: 'binary_not_found' } + } + + if (typeof message === 'string' && /\bspawn\b/i.test(message) && /\benoent\b/i.test(message)) { + return { error_class: 'binary_not_found' } + } + + return { error_class: 'unknown' } +} diff --git a/src/main/telemetry/client.test.ts b/src/main/telemetry/client.test.ts index 52a82af9409..2b76dd44c36 100644 --- a/src/main/telemetry/client.test.ts +++ b/src/main/telemetry/client.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // End-to-end behavior of the track() wrapper against a mock PostHog. These // tests pin the ordering contracts: // @@ -5,8 +6,8 @@ // - burst cap runs BEFORE consent resolve (opted-out flood does not hit // resolveConsent) // - per-session 1000-event ceiling enforced -// - opt-out event fires BEFORE posthog.optOut() — the one event that -// transmits against the user's new preference +// - opt-out event reaches the SDK queue BEFORE posthog.optOut() — the one +// event that transmits against the user's new preference // - serialized capture payload is exactly CommonProps ∪ EventProps ∪ the // allowed auto-properties ({ $process_person_profile }); an unexpected // auto-property from a future SDK upgrade fails the drift check @@ -25,9 +26,13 @@ import { _setPostHogClientForTests, _setShuttingDownForTests, _setStoreForTests, + _resetFirstAppOpenedFiredForTests, + persistBannerAcknowledgeWithoutEmitting, setOptIn, + shouldOptOutSdkAtInit, shutdownTelemetry, - track + track, + trackAppOpenedOnce } from './client' // Minimal mock of the PostHog client surface the wrapper actually calls. @@ -39,14 +44,42 @@ type MockPostHog = { optIn: ReturnType optOut: ReturnType shutdown: ReturnType + on: ReturnType + emitForTests: (event: string, payload: unknown) => void } function makeMockPostHog(): MockPostHog { + const listeners = new Map void>>() + const emitForTests = (event: string, payload: unknown): void => { + for (const listener of listeners.get(event) ?? []) { + listener(payload) + } + } + return { - capture: vi.fn(), - optIn: vi.fn(), - optOut: vi.fn(), - shutdown: vi.fn(async () => {}) + capture: vi.fn((message: { event?: string; uuid?: string }) => { + queueMicrotask(() => { + emitForTests('capture', { + event: message.event, + uuid: message.uuid + }) + }) + }), + optIn: vi.fn(async () => {}), + optOut: vi.fn(async () => {}), + shutdown: vi.fn(async () => {}), + on: vi.fn((event: string, listener: (payload: unknown) => void) => { + let eventListeners = listeners.get(event) + if (!eventListeners) { + eventListeners = new Set() + listeners.set(event, eventListeners) + } + eventListeners.add(listener) + return () => { + eventListeners?.delete(listener) + } + }), + emitForTests } } @@ -59,7 +92,10 @@ function makeFakeStore(settings: GlobalSettings): Store { getSettings: vi.fn(() => settings), updateSettings: vi.fn((updates: Partial) => { if (updates.telemetry) { - settings.telemetry = { ...settings.telemetry, ...updates.telemetry } as typeof settings.telemetry + settings.telemetry = { + ...settings.telemetry, + ...updates.telemetry + } as typeof settings.telemetry } return settings }) @@ -137,12 +173,14 @@ describe('track()', () => { _setStoreForTests(store) _setShuttingDownForTests(false) _enableTransportForTests(true) + _resetFirstAppOpenedFiredForTests() }) afterEach(() => { _enableTransportForTests(false) _setPostHogClientForTests(null) _setCommonPropsForTests(null) _setStoreForTests(null) + _resetFirstAppOpenedFiredForTests() vi.restoreAllMocks() restoreConsentEnv(envStash) }) @@ -237,12 +275,19 @@ describe('track()', () => { it('drops invalid events before calling capture', () => { // Raw error strings on agent_error are rejected by `.strict()`. track('agent_error', { - error_class: 'auth_expired', + error_class: 'unknown', agent_kind: 'claude-code', error_message: 'leaked message' // rejected by .strict() } as never) expect(mock.capture).not.toHaveBeenCalled() }) + + it('trackAppOpenedOnce emits app_opened at most once per session', () => { + trackAppOpenedOnce() + trackAppOpenedOnce() + expect(mock.capture).toHaveBeenCalledTimes(1) + expect(mock.capture.mock.calls[0]![0].event).toBe('app_opened') + }) }) describe('setOptIn()', () => { @@ -268,40 +313,152 @@ describe('setOptIn()', () => { _setStoreForTests(store) _setShuttingDownForTests(false) _enableTransportForTests(true) + _resetFirstAppOpenedFiredForTests() }) afterEach(() => { _enableTransportForTests(false) _setPostHogClientForTests(null) _setCommonPropsForTests(null) _setStoreForTests(null) + _resetFirstAppOpenedFiredForTests() vi.restoreAllMocks() restoreConsentEnv(envStash) }) // Ordering invariant: the opt-out event is the one signal that transmits - // against the user's new preference. It MUST reach capture() before we - // disable the SDK — otherwise the signal is lost. - it('fires telemetry_opted_out BEFORE posthog.optOut()', () => { + // against the user's new preference. It MUST reach the SDK queue before + // we disable the SDK — otherwise posthog-node drops it at enqueue time. + it('waits for telemetry_opted_out to enqueue before posthog.optOut()', async () => { const order: string[] = [] - mock.capture.mockImplementation(() => { - order.push('capture') + mock.capture.mockImplementation((message: { event?: string; uuid?: string }) => { + order.push('capture called') + queueMicrotask(() => { + order.push('sdk enqueue') + mock.emitForTests('capture', { + event: message.event, + uuid: message.uuid + }) + }) }) - mock.optOut.mockImplementation(() => { + mock.optOut.mockImplementation(async () => { order.push('optOut') }) - setOptIn('settings', false) - expect(order).toEqual(['capture', 'optOut']) + await setOptIn('settings', false) + expect(order).toEqual(['capture called', 'sdk enqueue', 'optOut']) }) - it('fires telemetry_opted_in AFTER posthog.optIn()', () => { + it('fires telemetry_opted_in after posthog.optIn without app_opened for settings opt-in', async () => { // Flip settings to currently-opted-out so the flip to true exercises - // the opt-in branch cleanly. + // the opt-in branch cleanly. This is not the pending-banner path, so + // it must not replay the once-per-session app_opened event. settings.telemetry!.optedIn = false const order: string[] = [] - mock.optIn.mockImplementation(() => order.push('optIn')) - mock.capture.mockImplementation(() => order.push('capture')) - setOptIn('settings', true) - expect(order).toEqual(['optIn', 'capture']) + mock.optIn.mockImplementation(async () => order.push('optIn')) + mock.capture.mockImplementation((message: { event?: string }) => { + order.push(`capture:${message.event}`) + }) + await setOptIn('settings', true) + expect(order).toEqual(['optIn', 'capture:telemetry_opted_in']) + }) + + it('drops telemetry_opted_in silently in non-official builds', async () => { + settings.telemetry!.optedIn = false + _setPostHogClientForTests(null) + _enableTransportForTests(false) + + await setOptIn('settings', true) + + expect(mock.capture).not.toHaveBeenCalled() + expect(console.debug).not.toHaveBeenCalled() + }) + + it('fires app_opened once after pending-banner opt-in enables the SDK', async () => { + settings.telemetry = { + optedIn: null, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: true + } + const order: string[] = [] + mock.optIn.mockImplementation(async () => order.push('optIn')) + mock.capture.mockImplementation((message: { event?: string }) => { + order.push(`capture:${message.event}`) + }) + + await setOptIn('settings', true) + + expect(order).toEqual(['optIn', 'capture:app_opened', 'capture:telemetry_opted_in']) + }) +}) + +describe('persistBannerAcknowledgeWithoutEmitting()', () => { + let mock: MockPostHog + let store: Store + let settings: GlobalSettings + let envStash: Record + + beforeEach(() => { + envStash = stashAndClearConsentEnv() + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + resetBurstCapsForSession() + mock = makeMockPostHog() + settings = makeFakeSettings({ + optedIn: null, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: true + }) + store = makeFakeStore(settings) + _setPostHogClientForTests(mock as unknown as PostHog) + _setCommonPropsForTests(BASE_COMMON) + _setStoreForTests(store) + _setShuttingDownForTests(false) + _enableTransportForTests(true) + _resetFirstAppOpenedFiredForTests() + }) + + afterEach(() => { + _enableTransportForTests(false) + _setPostHogClientForTests(null) + _setCommonPropsForTests(null) + _setStoreForTests(null) + _resetFirstAppOpenedFiredForTests() + vi.restoreAllMocks() + restoreConsentEnv(envStash) + }) + + it('fires app_opened after re-enabling the SDK and does not emit telemetry_opted_in', async () => { + const order: string[] = [] + mock.optIn.mockImplementation(async () => order.push('optIn')) + mock.capture.mockImplementation((message: { event?: string }) => { + order.push(`capture:${message.event}`) + }) + + await persistBannerAcknowledgeWithoutEmitting() + + expect(order).toEqual(['optIn', 'capture:app_opened']) + expect(settings.telemetry?.optedIn).toBe(true) + }) +}) + +// Pin the init-time SDK opt-out decision. The bug this test prevents: if +// `initTelemetry` flipped the SDK's `optedOut` flag for `pending_banner` +// (existing users who have not yet resolved the notice), the direct +// `posthog.capture(telemetry_opted_out)` on the Turn-off path would be +// dropped by posthog-core's enqueue — silently losing the one signal that +// tells us the existing-user opt-out flow works. +describe('shouldOptOutSdkAtInit()', () => { + it('opts out the SDK for every disabled-reason', () => { + for (const reason of ['user_opt_out', 'ci', 'do_not_track', 'orca_disabled'] as const) { + expect(shouldOptOutSdkAtInit({ effective: 'disabled', reason })).toBe(true) + } + }) + + it('does NOT opt out the SDK for pending_banner', () => { + expect(shouldOptOutSdkAtInit({ effective: 'pending_banner' })).toBe(false) + }) + + it('does NOT opt out the SDK for enabled', () => { + expect(shouldOptOutSdkAtInit({ effective: 'enabled' })).toBe(false) }) }) diff --git a/src/main/telemetry/client.ts b/src/main/telemetry/client.ts index 353822c8b87..c346bd41b5f 100644 --- a/src/main/telemetry/client.ts +++ b/src/main/telemetry/client.ts @@ -30,18 +30,23 @@ import { randomUUID } from 'node:crypto' import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os' import { app } from 'electron' import { PostHog } from 'posthog-node' -import type { CommonProps, EventName, EventProps } from '../../shared/telemetry-events' +import type { CommonProps, EventName, EventProps, OptInVia } from '../../shared/telemetry-events' import type { Store } from '../persistence' import { consumeBurstToken, resetBurstCapsForSession } from './burst-cap' -import { resolveConsent } from './consent' +import { getCohortAtEmit } from './cohort-classifier' +import { resolveConsent, type ConsentState } from './consent' import { commonPropsSchema, validate } from './validator' -// Compile-time feature flag. PR 2 ships with this `false` — the SDK is wired -// but no event transmits. PR 3 flips it to `true` once the PostHog project -// is live and dashboards are verified. Independent of the build-identity -// gate below: both must be satisfied to transmit, so flipping the flag -// alone still leaves contributor builds silent. -const TELEMETRY_ENABLED = false +// Compile-time feature flag. PR 2 shipped with this `false` so the SDK was +// wired but no event transmitted. PR 3 flips it to `true`. Independent of +// the build-identity gate below: both must be satisfied to transmit, so +// flipping the flag alone still leaves contributor builds silent. +// +// NOTE: config/scripts/verify-telemetry-constants.mjs greps this declaration +// shape (`const TELEMETRY_ENABLED = true|false`) to gate release verification. +// If you refactor this (e.g. let, export, computed-from-env, moved into a +// config object), update the regex in that script too. +const TELEMETRY_ENABLED = true // Eligible-to-transmit only if the CI release pipeline injected BOTH the // build-identity constant and a write key. One without the other is treated @@ -79,6 +84,8 @@ let commonProps: CommonProps | null = null let shuttingDown = false let storeRef: Store | null = null +const OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS = 1_000 + // Test-only override for the transport gate. Set by `_enableTransportForTests` // so the client.test.ts suite can exercise the full pipeline (burst cap, // consent, validator, capture) without waiting on a real CI build. Left @@ -86,11 +93,12 @@ let storeRef: Store | null = null // be bounded by `resolveConsent` + the validator. let testTransportEnabled = false -function buildCommonProps( - installId: string, - sid: string, - channel: 'stable' | 'rc' -): CommonProps { +// First-launch `app_opened` session gate. The existing-user banner contract is: +// no events transmit until the notice resolves. Keep "mark" and "emit" +// atomic so no path can accidentally suppress the event without firing it. +let appOpenedTrackedThisSession = false + +function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps { // `.max(64)` on every free-form string field in `commonPropsSchema` is the // upper bound; node's platform / arch / release strings are always well // under that in practice. We do not truncate here because the validator's @@ -114,6 +122,9 @@ export function initTelemetry(store: Store): void { storeRef = store resetBurstCapsForSession() shuttingDown = false + // Gate reset per session: the "no app_opened until banner resolution" + // invariant is per-launch, not across the lifetime of the install. + appOpenedTrackedThisSession = false if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD) { return @@ -172,31 +183,85 @@ export function initTelemetry(store: Store): void { maxQueueSize: 5000 }) - // Re-apply the user's persisted opt-out on every boot: the PostHog SDK's - // in-memory opt-out flag does NOT persist across process restarts, and - // `GlobalSettings.telemetry.optedIn` is what actually gates whether a user - // has said yes. Do not remove this re-apply thinking it is redundant with - // the persisted setting; the SDK flag is the thing that gates capture(). - const consent = resolveConsent(settings) - if (consent.effective !== 'enabled') { + if (shouldOptOutSdkAtInit(resolveConsent(settings))) { posthog.optOut() } } +/** + * Decide whether to flip the PostHog SDK's in-memory `optedOut` flag at boot. + * + * Applied to DISABLED cohorts only (`user_opt_out` / CI / DO_NOT_TRACK / + * ORCA_TELEMETRY_DISABLED). The SDK flag does not persist across process + * restarts, so we re-apply on every boot as defense-in-depth: any direct + * `posthog.capture()` that bypasses `track()` (and therefore bypasses the + * consent gate in this module) must still drop at the SDK boundary for a + * user who has opted out. + * + * Intentionally NOT applied to `pending_banner`: the existing-user Turn-off + * path in `setOptIn(_, false)` does a direct `posthog.capture()` for the + * `telemetry_opted_out { via: 'first_launch_banner' }` signal, bypassing + * `track()` (see the long comment in the opt-out branch below explaining + * why). If the SDK were already opted-out at that point, the capture would + * silently drop inside posthog-core's `enqueue()` — losing the one signal + * that tells us the opt-out flow works. `track()`'s own consent gate + * (`resolveConsent() !== 'enabled'`) still drops every other event while + * the cohort is `pending_banner`, so there is no risk of stray transmission + * during the pre-banner window. + * + * Exported for tests; production has exactly one call site above. + */ +export function shouldOptOutSdkAtInit(consent: ConsentState): boolean { + return consent.effective === 'disabled' +} + +function waitForCaptureEnqueue(client: PostHog, event: EventName, uuid: string): Promise { + return new Promise((resolve) => { + let settled = false + let stopListening: (() => void) | null = null + let timeout: ReturnType | null = null + + const settle = (enqueued: boolean): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + } + stopListening?.() + resolve(enqueued) + } + + // Why: posthog-node's capture() prepares/enqueues asynchronously; this + // public SDK event is the durable boundary we need before calling optOut(). + stopListening = client.on('capture', (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + return + } + const message = payload as { event?: unknown; uuid?: unknown } + if (message.event === event && message.uuid === uuid) { + settle(true) + } + }) + + timeout = setTimeout(() => settle(false), OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS) + }) +} + +// In `pnpm dev` and any contributor / non-official build, `track()` is a +// no-op: it returns immediately without transmitting, logging, or running +// the burst-cap / consent / validator pipeline. Telemetry only flows in +// official stable/rc builds where CI injects `ORCA_BUILD_IDENTITY` and +// `ORCA_POSTHOG_WRITE_KEY`. export function track(name: N, props: EventProps): void { - // Console mirror: always in non-official builds (so the whole team — - // contributors included — sees exactly what would transmit) and also in - // official builds when `TELEMETRY_ENABLED` is off (PR 2 verification). - // These are the only two paths that short-circuit before the pipeline. if (!testTransportEnabled && (!IS_OFFICIAL_BUILD || !TELEMETRY_ENABLED)) { - console.debug('[telemetry]', name, props) return } // (1) Shutdown gate. Late IPC arrivals should not attempt to enqueue // against a client that is actively flushing. if (shuttingDown) { - console.debug('[telemetry] shutdown-gate drop:', name) return } if (!posthog || !commonProps || !storeRef) { @@ -245,14 +310,15 @@ export function track(name: N, props: EventProps): void }) } -export function setOptIn( - via: 'settings' | 'first_launch_banner' | 'first_launch_notice', - optedIn: boolean -): void { +export async function setOptIn(via: OptInVia, optedIn: boolean): Promise { if (!storeRef) { return } const settings = storeRef.getSettings() + const telemetryBeforeUpdate = settings.telemetry + const wasPendingBanner = + telemetryBeforeUpdate?.existedBeforeTelemetryRelease === true && + telemetryBeforeUpdate.optedIn === null // `updateSettings` is a partial-merge (see persistence.ts:552). The Store's // `telemetry` field is deep-merged there specifically so an `optedIn` flip // from the Privacy pane / consent flow does not clobber `installId` or @@ -264,13 +330,19 @@ export function setOptIn( } }) - if (!posthog) { - return - } + const client = posthog if (optedIn) { - posthog.optIn() + if (client) { + await client.optIn() + } + if (wasPendingBanner) { + trackAppOpenedOnce() + } track('telemetry_opted_in', { via }) } else { + if (!client) { + return + } // Fire opt-out event BEFORE disabling the SDK. This is the one event // that transmits against the user's new preference — the user chose to // tell us they are opting out, and that single signal is what tells us @@ -283,24 +355,94 @@ export function setOptIn( // Burst cap + validator still run; consent is the only gate bypassed, // and it is bypassed exactly once per user per session at most (IPC // consent-mutation cap is 5/session). - if (!shuttingDown && commonProps && consumeBurstToken('telemetry_opted_out')) { - const validated = validate('telemetry_opted_out', { via }) - if (validated.ok) { - posthog.capture({ - distinctId: commonProps.install_id, - event: 'telemetry_opted_out', - properties: { - ...commonProps, - ...validated.props, - $process_person_profile: false + // + // posthog-node prepares capture() asynchronously, so "call capture + // before optOut" is not enough; wait until the SDK confirms enqueue. + // We do not wait for network flush here — the SDK queue and shutdown + // flush own delivery, while the enqueue boundary owns the optOut race. + try { + if (!shuttingDown && commonProps && consumeBurstToken('telemetry_opted_out')) { + const validated = validate('telemetry_opted_out', { via }) + if (validated.ok) { + const uuid = randomUUID() + const enqueued = waitForCaptureEnqueue(client, 'telemetry_opted_out', uuid) + client.capture({ + distinctId: commonProps.install_id, + event: 'telemetry_opted_out', + uuid, + properties: { + ...commonProps, + ...validated.props, + $process_person_profile: false + } + }) + if (!(await enqueued)) { + console.warn('[telemetry] telemetry_opted_out did not enqueue before SDK opt-out') } - }) + } } + } catch (err) { + console.warn('[telemetry] telemetry_opted_out capture failed before SDK opt-out:', err) + } finally { + await client.optOut() } - posthog.optOut() } } +// Banner ✕ path. Writes `optedIn = true` permanently without emitting a +// telemetry opt-in event. `app_opened` still fires because resolving the +// banner is the first point where this session is eligible to transmit. +// That outcome cannot route through `setOptIn()` — `setOptIn()` always +// fires a `telemetry_opted_in/out` event and the IPC handler always +// derives a non-`null` `via` value, which would tag a ✕ click as +// `first_launch_banner` + `telemetry_opted_in`. The ✕-as-silent- +// acknowledge contract is explicit: the user did not explicitly opt in, +// they declined to intervene, so no opt-in event transmits. +// +// So this primitive exists as a named, non-overloaded code path: persist +// the opt-in, unlock the SDK, and fire the once-per-session app-opened event. +// The corresponding `telemetry:acknowledgeBanner` IPC channel +// routes renderer ✕ clicks here instead of through `telemetry:setOptIn`. +// +// Do NOT extend this with a `via` parameter or emission flag. If a future +// surface also needs a silent persisted opt-in, give it its own named +// function rather than overloading this one — the grep'ability of +// `persistBannerAcknowledgeWithoutEmitting` is the whole point. +export async function persistBannerAcknowledgeWithoutEmitting(): Promise { + if (!storeRef) { + return + } + const settings = storeRef.getSettings() + // Defensive merge mirrors `setOptIn`: updateSettings deep-merges the + // telemetry block (persistence.ts:560), so the fallback object here only + // matters if the migration invariant has been violated and `telemetry` + // is somehow absent — in which case we still want to persist an opt-in + // rather than no-op. + storeRef.updateSettings({ + telemetry: { + ...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }), + optedIn: true + } + }) + if (posthog) { + await posthog.optIn() + } + // Why: resolving the banner is the first eligible moment for app_opened. + // Re-enable the SDK first so capture sees the new consent state. + trackAppOpenedOnce() +} + +export function trackAppOpenedOnce(): void { + if (appOpenedTrackedThisSession) { + return + } + appOpenedTrackedThisSession = true + // Why: `nth_repo_added: 0` on `app_opened` is the canonical session-zero + // / pre-repo cohort signal — a user who has launched but never added a + // repo. See docs/onboarding-funnel-cohort-addendum.md. + track('app_opened', { ...getCohortAtEmit() }) +} + export async function shutdownTelemetry(): Promise { // Setting the shutdown gate is synchronous and cheap — it matters that // late IPC-arrived tracks hit it before the bounded flush starts. @@ -348,3 +490,7 @@ export function _getSessionIdForTests(): string | null { export function _enableTransportForTests(enabled: boolean): void { testTransportEnabled = enabled } + +export function _resetFirstAppOpenedFiredForTests(): void { + appOpenedTrackedThisSession = false +} diff --git a/src/main/telemetry/cohort-classifier.test.ts b/src/main/telemetry/cohort-classifier.test.ts new file mode 100644 index 00000000000..2d1593cde8e --- /dev/null +++ b/src/main/telemetry/cohort-classifier.test.ts @@ -0,0 +1,101 @@ +// Pins the cohort-classifier contract: synchronous read of +// `store.getRepoCount()`, fail-soft to `undefined` on any failure mode, +// at most one warn per session. See docs/onboarding-funnel-cohort-addendum.md. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/types' +import type { Store } from '../persistence' +import { + _resetSessionWarnFlagForTests, + _setStoreForTests, + getCohortAtEmit, + initCohortClassifier +} from './cohort-classifier' + +function makeFakeStore(getRepos: () => Repo[]): Store { + // Both reads delegate to the same `getRepos` thunk so existing tests stay + // meaningful: a throw or a list change is observed identically through + // either accessor. + return { + getRepos: vi.fn(getRepos), + getRepoCount: vi.fn(() => getRepos().length) + } as unknown as Store +} + +function makeRepos(n: number): Repo[] { + return Array.from({ length: n }, (_, i) => ({ id: `repo-${i}` }) as unknown as Repo) +} + +describe('cohort-classifier', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + _setStoreForTests(null) + _resetSessionWarnFlagForTests() + }) + afterEach(() => { + vi.restoreAllMocks() + _setStoreForTests(null) + }) + + it('returns the current repo count', () => { + initCohortClassifier(makeFakeStore(() => makeRepos(3))) + expect(getCohortAtEmit()).toEqual({ nth_repo_added: 3 }) + }) + + // The session-zero / pre-repo cohort signal: a launch with no repos must + // emit `0`, not `undefined`. Filtering `nth_repo_added = 0` on + // `app_opened` is the canonical way to isolate the pre-repo cohort. + it('returns 0 (not undefined) for an empty repo list', () => { + initCohortClassifier(makeFakeStore(() => [])) + expect(getCohortAtEmit()).toEqual({ nth_repo_added: 0 }) + }) + + it('returns undefined when the store is not initialized', () => { + expect(getCohortAtEmit()).toEqual({ nth_repo_added: undefined }) + }) + + it('returns undefined and never throws when getRepos throws', () => { + initCohortClassifier( + makeFakeStore(() => { + throw new Error('disk fault') + }) + ) + expect(() => getCohortAtEmit()).not.toThrow() + expect(getCohortAtEmit()).toEqual({ nth_repo_added: undefined }) + }) + + // Why: a degraded boot should not flood stderr; the warn-once flag is the + // breadcrumb mechanism for "why is some chunk of last week's data + // missing nth_repo_added?" without burning logs on every emit. + it('warns at most once per session even across many degraded calls', () => { + initCohortClassifier( + makeFakeStore(() => { + throw new Error('disk fault') + }) + ) + const warnSpy = console.warn as unknown as ReturnType + for (let i = 0; i < 50; i++) { + getCohortAtEmit() + } + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + // The session-warn flag resets on initCohortClassifier so a fresh + // process gets a fresh breadcrumb budget. + it('resets the warn flag when reinitialized', () => { + initCohortClassifier( + makeFakeStore(() => { + throw new Error('first') + }) + ) + getCohortAtEmit() + initCohortClassifier( + makeFakeStore(() => { + throw new Error('second') + }) + ) + getCohortAtEmit() + const warnSpy = console.warn as unknown as ReturnType + expect(warnSpy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/telemetry/cohort-classifier.ts b/src/main/telemetry/cohort-classifier.ts new file mode 100644 index 00000000000..80c01bff7c6 --- /dev/null +++ b/src/main/telemetry/cohort-classifier.ts @@ -0,0 +1,64 @@ +// Single source of truth for cohort state attached to telemetry events. +// See docs/onboarding-funnel-cohort-addendum.md. +// +// `nth_repo_added` is the count of repos the user has at the moment the +// event fires — read from `store.getRepoCount()`. The rule is single +// and consistent: the value reflects current store state at emit time. +// On `repo_added` the read happens *after* `store.addRepo` lands, so the +// user's Nth repo addition emits `N` (the just-landed write is included). +// On every other event, the same read returns whatever the count is — +// including `0` for a brand-new user on `app_opened` who has never added +// a repo. That `0` is the canonical session-zero / pre-repo cohort signal, +// not a sentinel and not "undefined." +// +// Failure mode: this module never throws. On any read error or +// store-not-yet-initialized condition, `getCohortAtEmit` returns +// `{ nth_repo_added: undefined }`. The schemas declare the field +// `.optional()`, so an event with an undefined cohort still validates and +// emits — it just lands without the cohort property. This preserves the +// telemetry rule "must never crash the app." + +import type { Store } from '../persistence' + +let storeRef: Store | null = null + +// Session-scoped flag analogous to `appOpenedTrackedThisSession` in +// `client.ts`: emit one debug breadcrumb per session if the classifier +// has to fail soft, so missing cohort data has a corresponding log line +// without flooding stderr. +let warnedThisSession = false + +export function initCohortClassifier(store: Store): void { + storeRef = store + warnedThisSession = false +} + +export function getCohortAtEmit(): { nth_repo_added: number | undefined } { + if (!storeRef) { + warnOnce('store not initialized') + return { nth_repo_added: undefined } + } + try { + const length = storeRef.getRepoCount() + return { nth_repo_added: length } + } catch (err) { + warnOnce(err instanceof Error ? err.message : String(err)) + return { nth_repo_added: undefined } + } +} + +function warnOnce(reason: string): void { + if (warnedThisSession) { + return + } + warnedThisSession = true + console.warn('[telemetry-cohort] classifier returned undefined', { reason }) +} + +export function _setStoreForTests(store: Store | null): void { + storeRef = store +} + +export function _resetSessionWarnFlagForTests(): void { + warnedThisSession = false +} diff --git a/src/main/telemetry/consent.ts b/src/main/telemetry/consent.ts index b72e79c4bb2..85ed0dfd481 100644 --- a/src/main/telemetry/consent.ts +++ b/src/main/telemetry/consent.ts @@ -7,6 +7,7 @@ // the variable on the next launch restores the user's stored preference. import type { GlobalSettings } from '../../shared/types' +import type { TelemetryConsentState } from '../../shared/telemetry-consent-types' // Discriminated union instead of a boolean: the Privacy pane (PR 3) needs the // `reason` to render the correct "disabled because X" helper text, and the @@ -14,13 +15,11 @@ import type { GlobalSettings } from '../../shared/types' // (`pending_banner`) from "user explicitly opted out" (`disabled`). A boolean // would force the UI to re-derive that, re-introducing the scattered env // checks this module exists to eliminate. -export type ConsentState = - | { effective: 'enabled' } - | { - effective: 'disabled' - reason: 'do_not_track' | 'orca_disabled' | 'ci' | 'user_opt_out' - } - | { effective: 'pending_banner' } +// +// The type itself lives in `shared/telemetry-consent-types.ts` so the +// renderer can import it across the IPC boundary; this re-export keeps +// existing call sites in main working without a rename. +export type ConsentState = TelemetryConsentState // Precedence for the `disabled` branches is documented alongside // `resolveConsent` below. diff --git a/src/main/telemetry/install-id.test.ts b/src/main/telemetry/install-id.test.ts index 0bf934369ed..69591a72663 100644 --- a/src/main/telemetry/install-id.test.ts +++ b/src/main/telemetry/install-id.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest' import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' -import { generateInstallId, readInstallId, resetInstallId } from './install-id' +import { generateInstallId, readInstallId } from './install-id' // Minimal in-memory store stand-in. The real `Store` pulls in Electron + fs, // which is overkill for testing the install-id read/write contract. We only @@ -48,44 +48,4 @@ describe('install-id', () => { expect(readInstallId(store)).toBeUndefined() }) }) - - describe('resetInstallId', () => { - it('generates a new id, persists it, and preserves sibling telemetry fields', () => { - const store = makeFakeStore({ - telemetry: { - optedIn: true, - installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - existedBeforeTelemetryRelease: false, - firstRunNoticeShown: true - } - }) - - const newId = resetInstallId(store) - - expect(newId).not.toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - expect(newId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) - - // Read-back should reflect the new id and keep everything else intact. - expect(store.getSettings().telemetry).toEqual({ - optedIn: true, - installId: newId, - existedBeforeTelemetryRelease: false, - firstRunNoticeShown: true - }) - }) - - it('initializes telemetry defensively if the block is somehow missing', () => { - // Should not happen after migration — exercised to prove the Privacy - // pane button will not throw if the user reaches it before migration - // has run (startup-ordering bug). We prefer a usable store over a - // silent crash; a future maintainer should leave this branch in place. - const store = makeFakeStore({}) - const newId = resetInstallId(store) - expect(store.getSettings().telemetry).toEqual({ - optedIn: null, - installId: newId, - existedBeforeTelemetryRelease: true - }) - }) - }) }) diff --git a/src/main/telemetry/install-id.ts b/src/main/telemetry/install-id.ts index 5351c688dc9..04c6d89a61f 100644 --- a/src/main/telemetry/install-id.ts +++ b/src/main/telemetry/install-id.ts @@ -1,20 +1,11 @@ import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' -// Anonymous UUID v4 that keys the user as a telemetry subject. Two contracts -// this module must preserve: -// -// 1. Stability across launches. The migration in `persistence.ts` populates -// `GlobalSettings.telemetry.installId` once; `readInstallId` is the sole -// read path so call sites cannot accidentally regenerate it by reaching -// into the store themselves. -// -// 2. Rotability from the Privacy pane. `resetInstallId` generates a fresh -// UUID and persists it via `store.updateSettings` so the normal debounced -// save picks it up. PR 3 wires the Privacy pane button to this; PR 2 -// wires the PostHog reset(). In PR 1 the reset path exists but has no -// caller — keeping it here (vs. client.ts) keeps pure install-id logic in -// one file and leaves `client.ts` a thin vendor wrapper. +// Anonymous UUID v4 that keys the user as a telemetry subject. Stability +// across launches is the single contract this module preserves: the +// migration in `persistence.ts` populates `GlobalSettings.telemetry.installId` +// once, and `readInstallId` is the sole read path so call sites cannot +// accidentally regenerate it by reaching into the store themselves. export function generateInstallId(): string { return randomUUID() @@ -28,29 +19,3 @@ export function generateInstallId(): string { export function readInstallId(store: Store): string | undefined { return store.getSettings().telemetry?.installId } - -// Rotation path used by the Privacy pane's "Reset anonymous ID" button. Why -// `updateSettings` instead of a bespoke setter: `updateSettings` already -// performs the deep-merge that keeps `notifications` intact, schedules the -// 300 ms debounced save, and is the one sanctioned way to mutate persisted -// settings. Duplicating the merge path risks dropping sibling fields on a -// future schema addition. -export function resetInstallId(store: Store): string { - const settings = store.getSettings() - const telemetry = settings.telemetry - // Defensive: migration should have populated this by the time the Privacy - // pane can call reset. If it somehow hasn't, initialize rather than throw - // — the user clicked a button; failing silently loudly is worse than - // returning a fresh ID. - const newId = generateInstallId() - store.updateSettings({ - telemetry: telemetry - ? { ...telemetry, installId: newId } - : { - optedIn: null, - installId: newId, - existedBeforeTelemetryRelease: true - } - }) - return newId -} diff --git a/src/main/telemetry/onboarding-cohort-classifier.test.ts b/src/main/telemetry/onboarding-cohort-classifier.test.ts new file mode 100644 index 00000000000..25aa5e2f67e --- /dev/null +++ b/src/main/telemetry/onboarding-cohort-classifier.test.ts @@ -0,0 +1,142 @@ +// Pins the onboarding-cohort-classifier contract: synchronous read of +// `existedBeforeTelemetryRelease` plus the upgrade-backfill onboarding +// shape, fail-soft to `undefined` on any failure mode, at most one warn per +// session. See docs/onboarding-telemetry-extensions.md §2. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ONBOARDING_FINAL_STEP } from '../../shared/constants' +import type { GlobalSettings, OnboardingState } from '../../shared/types' +import type { Store } from '../persistence' +import { + _resetSessionWarnFlagForTests, + _setStoreForTests, + getOnboardingCohortAtEmit, + initOnboardingCohortClassifier +} from './onboarding-cohort-classifier' + +type ExistedBefore = boolean | null + +function makeFakeStore(opts: { + existedBefore: ExistedBefore + onboarding?: Partial + throwOnSettings?: boolean + throwOnOnboarding?: boolean +}): Store { + return { + getSettings: vi.fn((): GlobalSettings => { + if (opts.throwOnSettings) { + throw new Error('disk fault') + } + return { + telemetry: { + installId: 'fake', + optedIn: true, + existedBeforeTelemetryRelease: + opts.existedBefore === null ? undefined : opts.existedBefore + } + } as unknown as GlobalSettings + }), + getOnboarding: vi.fn((): OnboardingState => { + if (opts.throwOnOnboarding) { + throw new Error('disk fault') + } + return { + outcome: null, + lastCompletedStep: -1, + closedAt: null, + checklist: {}, + ...opts.onboarding + } as OnboardingState + }) + } as unknown as Store +} + +describe('onboarding-cohort-classifier', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + _setStoreForTests(null) + _resetSessionWarnFlagForTests() + }) + afterEach(() => { + vi.restoreAllMocks() + _setStoreForTests(null) + }) + + it('classifies a fresh-install user as fresh_install', () => { + initOnboardingCohortClassifier(makeFakeStore({ existedBefore: false })) + expect(getOnboardingCohortAtEmit()).toEqual({ cohort: 'fresh_install' }) + }) + + it('classifies an existing user with backfilled completion as upgrade_backfill', () => { + initOnboardingCohortClassifier( + makeFakeStore({ + existedBefore: true, + onboarding: { outcome: 'completed', lastCompletedStep: ONBOARDING_FINAL_STEP } + }) + ) + expect(getOnboardingCohortAtEmit()).toEqual({ cohort: 'upgrade_backfill' }) + }) + + it('classifies a live-completed existing user as upgrade_backfill (known limitation)', () => { + // Pins current behavior: a real existing-user who completes the wizard + // live writes the same canonical (`outcome: 'completed'`, + // `lastCompletedStep === ONBOARDING_FINAL_STEP`) shape that the migration + // backfill writes, so the classifier cannot distinguish them. This test + // documents the limitation; dashboards should filter `cohort` on + // `_started` and forward-fill across the session. + initOnboardingCohortClassifier( + makeFakeStore({ + existedBefore: true, + onboarding: { + outcome: 'completed', + lastCompletedStep: ONBOARDING_FINAL_STEP, + closedAt: 1234567890 + } + }) + ) + expect(getOnboardingCohortAtEmit()).toEqual({ cohort: 'upgrade_backfill' }) + }) + + it('classifies an existing user mid-wizard as fresh_install (the funnel cohort)', () => { + // Existing user but the onboarding state isn't the migration's + // canonical force-completed shape. Funnel-wise this user is going + // through the wizard live, so they belong with `fresh_install`. + initOnboardingCohortClassifier( + makeFakeStore({ + existedBefore: true, + onboarding: { outcome: null, lastCompletedStep: 1 } + }) + ) + expect(getOnboardingCohortAtEmit()).toEqual({ cohort: 'fresh_install' }) + }) + + it('returns undefined when existedBeforeTelemetryRelease is unset', () => { + initOnboardingCohortClassifier(makeFakeStore({ existedBefore: null })) + expect(getOnboardingCohortAtEmit()).toEqual({ cohort: undefined }) + }) + + it('returns undefined when the store is not initialized', () => { + expect(getOnboardingCohortAtEmit()).toEqual({ cohort: undefined }) + }) + + it('never throws and returns undefined when getSettings throws', () => { + initOnboardingCohortClassifier(makeFakeStore({ existedBefore: false, throwOnSettings: true })) + expect(() => getOnboardingCohortAtEmit()).not.toThrow() + expect(getOnboardingCohortAtEmit()).toEqual({ cohort: undefined }) + }) + + it('never throws and returns undefined when getOnboarding throws', () => { + initOnboardingCohortClassifier(makeFakeStore({ existedBefore: true, throwOnOnboarding: true })) + expect(() => getOnboardingCohortAtEmit()).not.toThrow() + expect(getOnboardingCohortAtEmit()).toEqual({ cohort: undefined }) + }) + + it('warns at most once per session even across many degraded calls', () => { + initOnboardingCohortClassifier(makeFakeStore({ existedBefore: false, throwOnSettings: true })) + const warnSpy = console.warn as unknown as ReturnType + for (let i = 0; i < 50; i++) { + getOnboardingCohortAtEmit() + } + expect(warnSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/telemetry/onboarding-cohort-classifier.ts b/src/main/telemetry/onboarding-cohort-classifier.ts new file mode 100644 index 00000000000..d065eb65989 --- /dev/null +++ b/src/main/telemetry/onboarding-cohort-classifier.ts @@ -0,0 +1,107 @@ +// Cohort discriminator for onboarding-wizard telemetry events. See +// docs/onboarding-telemetry-extensions.md §2. +// +// `'fresh_install'` ⇔ `existedBeforeTelemetryRelease === false`. New users +// land on the wizard on first launch; the cohort never moves. +// +// `'upgrade_backfill'` ⇔ `existedBeforeTelemetryRelease === true` AND the +// onboarding state was backfilled at load time as a completed wizard run +// (the persistence migration at `src/main/persistence.ts:362-369` writes +// `outcome: 'completed'` and `lastCompletedStep: ONBOARDING_FINAL_STEP` for +// existing users that lack an onboarding block). A pre-existing user who is +// dropped into the wizard via the upgrade-backfill surface emits this +// cohort on every wizard event. +// +// Known limitation: the discriminator infers the migration-backfilled state +// from its canonical shape (`outcome === 'completed'` AND +// `lastCompletedStep === ONBOARDING_FINAL_STEP`). That shape is *also* what +// a live wizard completion writes via `closeWith('completed', ...)`. As a +// result, a real existing-user (`existedBeforeTelemetryRelease === true`) +// who goes through the wizard live will be classified as `fresh_install` +// during the wizard, then *flip* to `upgrade_backfill` on the very next +// event after `closeWith` persists the completion. Dashboard-side +// workaround: filter `cohort` on the `_started` event and forward-fill +// across the session, rather than re-reading the cohort on terminal +// events. Structural follow-up (out of scope here): add a sentinel +// `wasBackfilledByMigration: true` field at migration time so the +// classifier can disambiguate without dashboard-side gymnastics. +// +// Failure mode: this module never throws. On any read error or +// store-not-yet-initialized condition, `getOnboardingCohortAtEmit` returns +// `{ cohort: undefined }`. The schemas declare the field `.optional()`, so +// an event with an undefined cohort still validates and emits — it just +// lands without the cohort property. Mirrors `getCohortAtEmit`. + +import { ONBOARDING_FINAL_STEP } from '../../shared/constants' +import type { OnboardingCohort } from '../../shared/telemetry-events' +import type { Store } from '../persistence' + +let storeRef: Store | null = null + +let warnedThisSession = false + +export function initOnboardingCohortClassifier(store: Store): void { + storeRef = store + warnedThisSession = false +} + +export function getOnboardingCohortAtEmit(): { cohort: OnboardingCohort | undefined } { + if (!storeRef) { + warnOnce('store not initialized') + return { cohort: undefined } + } + try { + // Why: fresh_install classification depends only on the settings flag, + // so we read settings first and skip getOnboarding() entirely on that + // branch — a failing onboarding read must not demote a fresh-install + // user to `{ cohort: undefined }`. + const settings = storeRef.getSettings() + const existedBefore = settings.telemetry?.existedBeforeTelemetryRelease + if (existedBefore === false) { + return { cohort: 'fresh_install' } + } + if (existedBefore === true) { + // Why: an existing-user cohort marker can coexist with a fresh + // wizard run (the migration only backfills when there's no + // onboarding block on disk). The `upgrade_backfill` cohort is + // specifically the user who was force-completed by the migration — + // detected by the canonical `outcome === 'completed'` AND + // `lastCompletedStep === ONBOARDING_FINAL_STEP` shape that + // persistence.ts:362-369 writes. Caveat: the same canonical shape is + // produced by `closeWith('completed', ...)` after a live wizard run, + // so an existing user who completes the wizard live will be + // classified as `fresh_install` during the wizard and then flip to + // `upgrade_backfill` on the next event after completion is + // persisted. See the top-of-file "Known limitation" block for the + // dashboard-side workaround and the proposed sentinel-field fix. + const onboarding = storeRef.getOnboarding() + if ( + onboarding.outcome === 'completed' && + onboarding.lastCompletedStep === ONBOARDING_FINAL_STEP + ) { + return { cohort: 'upgrade_backfill' } + } + return { cohort: 'fresh_install' } + } + return { cohort: undefined } + } catch (err) { + warnOnce(err instanceof Error ? err.message : String(err)) + return { cohort: undefined } + } +} + +function warnOnce(reason: string): void { + if (warnedThisSession) { + return + } + warnedThisSession = true + console.warn('[telemetry-onboarding-cohort] classifier returned undefined', { reason }) +} + +export function _setStoreForTests(store: Store | null): void { + storeRef = store +} + +export function _resetSessionWarnFlagForTests(): void { + warnedThisSession = false +} diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts index 3da5b021ba2..534b5c656f9 100644 --- a/src/main/telemetry/validator.test.ts +++ b/src/main/telemetry/validator.test.ts @@ -49,7 +49,7 @@ describe('validate', () => { // transmits. it('rejects error_message on agent_error', () => { const result = validate('agent_error', { - error_class: 'auth_expired', + error_class: 'unknown', agent_kind: 'claude-code', error_message: 'at /Users/alice/secret/path/index.ts:42' } as never) @@ -58,7 +58,7 @@ describe('validate', () => { it('rejects error_stack on agent_error', () => { const result = validate('agent_error', { - error_class: 'auth_expired', + error_class: 'unknown', agent_kind: 'claude-code', error_stack: 'Error: boom\n at /Users/alice/...' } as never) @@ -83,40 +83,256 @@ describe('validate', () => { expect(result.ok).toBe(false) }) - it('drops overlength strings past the .max() cap', () => { - // commonPropsSchema's `.max(64)` caps don't live on per-event schemas — - // the per-event schemas use enum-only strings — so we exercise the cap - // via a whitelisted `error_name` that is *one specific enum value* - // meaning any attempt to smuggle a long string fails the enum check - // regardless. For explicit string-length cap coverage we also confirm - // the agent_error schema does not accept an overlength error_name - // even if it matches the prefix of a whitelisted value. + it('accepts a well-formed agent_error payload', () => { const result = validate('agent_error', { - error_class: 'auth_expired', - agent_kind: 'claude-code', - error_name: 'AuthExpiredButAlsoWithExtraGarbageThatMakesItTooLong' - } as never) - expect(result.ok).toBe(false) - }) - - it('accepts whitelisted error_name on agent_error', () => { - const result = validate('agent_error', { - error_class: 'auth_expired', - agent_kind: 'claude-code', - error_name: 'AuthExpired' + error_class: 'binary_not_found', + agent_kind: 'claude-code' }) expect(result.ok).toBe(true) }) - it('rejects error_name outside the whitelist', () => { + it('rejects agent_error with a deferred enum value', () => { + // `auth_expired` was in an earlier draft; the trimmed enum is + // ['binary_not_found', 'unknown']. Pin that contract so re-introducing + // a deferred value silently is impossible. const result = validate('agent_error', { error_class: 'auth_expired', - agent_kind: 'claude-code', - error_name: 'CustomErrorNotInList' + agent_kind: 'claude-code' } as never) expect(result.ok).toBe(false) }) + // ── Cohort property (nth_repo_added) ──────────────────────────────── + // Pin the schema contract from + // docs/onboarding-funnel-cohort-addendum.md. The field is optional so a + // classifier degraded-mode `undefined` still validates; rejected shapes + // (negative, non-integer, string) must drop. + + it('accepts app_opened with nth_repo_added=0 (the session-zero cohort signal)', () => { + const result = validate('app_opened', { nth_repo_added: 0 }) + expect(result.ok).toBe(true) + }) + + it('accepts repo_added with nth_repo_added=1', () => { + const result = validate('repo_added', { method: 'folder_picker', nth_repo_added: 1 }) + expect(result.ok).toBe(true) + }) + + it('accepts events without nth_repo_added (classifier degraded mode)', () => { + const result = validate('agent_started', { + agent_kind: 'claude-code', + launch_source: 'command_palette', + request_kind: 'new' + }) + expect(result.ok).toBe(true) + }) + + it('rejects negative nth_repo_added', () => { + const result = validate('app_opened', { nth_repo_added: -1 } as never) + expect(result.ok).toBe(false) + }) + + it('rejects non-integer nth_repo_added', () => { + const result = validate('app_opened', { nth_repo_added: 1.5 } as never) + expect(result.ok).toBe(false) + }) + + it('rejects nth_repo_added on a non-cohort event (settings_changed)', () => { + // The IPC handler relies on this rejection: schemas are `.strict()`, + // so injecting `nth_repo_added` on an event whose schema does not + // declare it must drop the entire event. The selectivity guard in + // `telemetry:track` is what prevents that from happening in practice. + const result = validate('settings_changed', { + setting_key: 'editorAutoSave', + value_kind: 'bool', + nth_repo_added: 1 + } as never) + expect(result.ok).toBe(false) + }) + + // ── Onboarding extensions (docs/onboarding-telemetry-extensions.md) ─ + + it('accepts onboarding_agent_picked with all required fields', () => { + const result = validate('onboarding_agent_picked', { + agent_kind: 'claude-code', + on_path: true, + detected_count: 2, + detection_state: 'complete', + from_collapsed_section: false + }) + expect(result.ok).toBe(true) + }) + + it('accepts onboarding_agent_picked with cohort injected', () => { + // Mirrors the IPC handler injection: schemas declare `cohort` as + // `.optional()`, so the classifier-injected value must not trip + // `.strict()`. + const result = validate('onboarding_agent_picked', { + agent_kind: 'codex', + on_path: false, + detected_count: 0, + detection_state: 'pending', + from_collapsed_section: true, + cohort: 'fresh_install' + }) + expect(result.ok).toBe(true) + }) + + it('rejects onboarding_agent_picked with unknown detection_state', () => { + const result = validate('onboarding_agent_picked', { + agent_kind: 'claude-code', + on_path: true, + detected_count: 1, + detection_state: 'detecting', + from_collapsed_section: false + } as never) + expect(result.ok).toBe(false) + }) + + it('accepts onboarding_agent_picked with path_source and path_failure_reason', () => { + // Why: the on_path:false triage instrumentation — + // see docs/agent-on-path-detection.md. + const result = validate('onboarding_agent_picked', { + agent_kind: 'claude-code', + on_path: false, + detected_count: 0, + detection_state: 'complete', + from_collapsed_section: false, + path_source: 'sync_seed_only', + path_failure_reason: 'timeout' + }) + expect(result.ok).toBe(true) + }) + + it('accepts onboarding_agent_picked without the new optional path fields', () => { + // Pre-deploy events validate cleanly under `.optional()`. + const result = validate('onboarding_agent_picked', { + agent_kind: 'codex', + on_path: true, + detected_count: 2, + detection_state: 'complete', + from_collapsed_section: false + }) + expect(result.ok).toBe(true) + }) + + it('rejects onboarding_agent_picked with unknown path_source', () => { + const result = validate('onboarding_agent_picked', { + agent_kind: 'claude-code', + on_path: true, + detected_count: 1, + detection_state: 'complete', + from_collapsed_section: false, + path_source: 'env_path' + } as never) + expect(result.ok).toBe(false) + }) + + it('rejects onboarding_agent_picked with unknown path_failure_reason', () => { + const result = validate('onboarding_agent_picked', { + agent_kind: 'claude-code', + on_path: true, + detected_count: 1, + detection_state: 'complete', + from_collapsed_section: false, + path_failure_reason: 'parse_failed' + } as never) + expect(result.ok).toBe(false) + }) + + it('accepts onboarding_ghostty_discovered with field_group_count_bucket', () => { + const result = validate('onboarding_ghostty_discovered', { + state: 'found', + field_group_count_bucket: '4-7' + }) + expect(result.ok).toBe(true) + }) + + it('rejects onboarding_ghostty_discovered with raw count instead of bucket', () => { + // Pin the privacy-doctrine contract: raw counts are an environment + // fingerprint and must not ship. + const result = validate('onboarding_ghostty_discovered', { + state: 'found', + field_group_count_bucket: 5 + } as never) + expect(result.ok).toBe(false) + }) + + it('accepts onboarding_ghostty_import_clicked with no payload', () => { + const result = validate('onboarding_ghostty_import_clicked', {}) + expect(result.ok).toBe(true) + }) + + it('accepts onboarding_ghostty_import_failed with each enum reason', () => { + for (const reason of ['no_config', 'empty_diff', 'unknown'] as const) { + const result = validate('onboarding_ghostty_import_failed', { reason }) + expect(result.ok).toBe(true) + } + }) + + it('accepts onboarding_step_completed with duration_ms and advanced_via', () => { + const result = validate('onboarding_step_completed', { + step: 1, + value_kind: 'agent', + duration_ms: 1234, + advanced_via: 'keyboard' + }) + expect(result.ok).toBe(true) + }) + + it('accepts onboarding_step_completed without the new optional fields', () => { + // Pre-deploy events (no `duration_ms`, no `advanced_via`) must still + // validate cleanly — that's the point of `.optional()`. + const result = validate('onboarding_step_completed', { + step: 2, + value_kind: 'theme' + }) + expect(result.ok).toBe(true) + }) + + it('rejects onboarding_step_completed with negative duration_ms', () => { + const result = validate('onboarding_step_completed', { + step: 1, + value_kind: 'agent', + duration_ms: -5 + } as never) + expect(result.ok).toBe(false) + }) + + it('rejects onboarding_step_completed with unknown advanced_via', () => { + const result = validate('onboarding_step_completed', { + step: 1, + value_kind: 'agent', + advanced_via: 'voice' + } as never) + expect(result.ok).toBe(false) + }) + + it('accepts onboarding_started with cohort upgrade_backfill', () => { + const result = validate('onboarding_started', { cohort: 'upgrade_backfill' }) + expect(result.ok).toBe(true) + }) + + it('rejects cohort on a non-onboarding event', () => { + // The IPC injection set is derived from `'cohort' in schema.shape`; + // strict() rejection here is what makes that selectivity safe. + const result = validate('app_opened', { + cohort: 'fresh_install' + } as never) + expect(result.ok).toBe(false) + }) + + it('accepts onboarding_started with cohort: undefined (classifier fail-soft)', () => { + // The IPC handler injects `getOnboardingCohortAtEmit()` even when it + // returns `{ cohort: undefined }` — the spread `{ ...withRepoCohort, + // ...{ cohort: undefined } }` produces an explicit-undefined key, not a + // missing key. Zod's `.optional()` treats those as the same; this test + // pins the behavior so the load-bearing fail-soft path is not silently + // broken by a future zod or schema change. + const result = validate('onboarding_started', { cohort: undefined }) + expect(result.ok).toBe(true) + }) + // Rate-limit: at most one warn per event name per 60s. We cannot easily // control Date.now() without mocking time, so the coarse assertion is // that repeat-dropping the same event name does not emit a warn on every diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index ba4ea4e2540..63c7344524d 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -309,9 +309,8 @@ function gcScanRoot( return result } -/** Run background GC to prune history directories for worktrees that no - * longer exist. Must be called after live worktree enumeration is complete - * to avoid deleting history for worktrees that haven't been discovered yet. */ +/** Run background GC to prune history directories for worktrees that are no + * longer in Orca's known live-worktree set. */ export function runHistoryGc(liveWorktreeIds: Set): void { try { const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds) @@ -348,8 +347,7 @@ export function runHistoryGc(liveWorktreeIds: Set): void { } /** Schedule GC after a delay so it runs after workspace hydration completes. - * `getLiveWorktreeIds` should enumerate all currently known worktree IDs - * (e.g. by listing repos and their git worktrees). */ + * `getLiveWorktreeIds` should use already-known IDs, not probe repo paths. */ export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise>): void { // Why 10s: avoids competing with startup-critical I/O while still running // early enough to clean up before the user notices disk usage (§7.6). diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index e12a3bab32a..53871a2e730 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -33,6 +33,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, autoUpdaterMock.checkForUpdates.mockReset() autoUpdaterMock.downloadUpdate.mockReset() autoUpdaterMock.quitAndInstall.mockReset() + autoUpdaterMock.setFeedURL.mockClear() } const autoUpdaterMock = { @@ -91,6 +92,20 @@ vi.mock('./updater-nudge', () => ({ shouldApplyNudge: vi.fn().mockReturnValue(false) })) +const ONE_HOUR_MS = 60 * 60 * 1000 +const THIRTY_SECONDS_MS = 30 * 1000 +const FRIENDLY_MESSAGE = "Couldn't reach the update server. Try again in a few minutes." + +function makeBenignCheckFailure(message: string): void { + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('error', new Error(message)) + }) + return Promise.reject(new Error(message)) + }) +} + describe('updater check failure handling', () => { beforeEach(() => { vi.resetModules() @@ -105,63 +120,53 @@ describe('updater check failure handling', () => { isMock.dev = false killAllPtyMock.mockReset() vi.unstubAllGlobals() + vi.useRealTimers() }) - it('surfaces GitHub release transition errors to user-initiated checks', async () => { - autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => { - autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub')) - }) - return Promise.reject(new Error('Unable to find latest version on GitHub')) - }) + it('surfaces GitHub release-transition failures with calmer copy and no short retry', async () => { + vi.useFakeTimers() + makeBenignCheckFailure('Unable to find latest version on GitHub') const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') - setupAutoUpdater(mainWindow as never) + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) checkForUpdatesFromMenu() - await vi.waitFor(() => { - const statuses = sendMock.mock.calls - .filter(([channel]) => channel === 'updater:status') - .map(([, status]) => status) - // Why: a user-initiated benign failure must show a visible error. Silently - // sending 'idle' (or 'not-available') makes the button look broken. - expect(statuses).toContainEqual( - expect.objectContaining({ - state: 'error', - userInitiated: true, - message: expect.stringContaining('GitHub may be temporarily unavailable') - }) - ) - expect(statuses).not.toContainEqual( - expect.objectContaining({ state: 'not-available', userInitiated: true }) - ) - }) + + await vi.advanceTimersByTimeAsync(0) + + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + + expect(statuses).toContainEqual({ state: 'checking', userInitiated: true }) + expect(statuses).toContainEqual( + expect.objectContaining({ + state: 'error', + userInitiated: true, + message: FRIENDLY_MESSAGE + }) + ) + expect(statuses).not.toContainEqual( + expect.objectContaining({ message: expect.stringContaining('Unable to find latest version') }) + ) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(THIRTY_SECONDS_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) }) - it('surfaces missing latest-mac.yml to user-initiated checks', async () => { - autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => { - autoUpdaterMock.emit( - 'error', - new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404') - ) - }) - return Promise.reject( - new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404') - ) - }) + it('surfaces missing latest-mac.yml to user-initiated checks with calmer copy', async () => { + makeBenignCheckFailure('Cannot find channel "latest-mac.yml" update info: HttpError: 404') const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') - setupAutoUpdater(mainWindow as never) + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) checkForUpdatesFromMenu() await vi.waitFor(() => { const statuses = sendMock.mock.calls @@ -171,7 +176,7 @@ describe('updater check failure handling', () => { expect.objectContaining({ state: 'error', userInitiated: true, - message: expect.stringContaining('GitHub may be temporarily unavailable') + message: FRIENDLY_MESSAGE }) ) expect(statuses).not.toContainEqual( @@ -180,30 +185,32 @@ describe('updater check failure handling', () => { }) }) - it('silently drops background benign failures to idle', async () => { - // Why: background checks must stay quiet; only user-initiated clicks get - // an error card. This prevents noisy nag during a release transition. - autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => { - autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub')) - }) - return Promise.reject(new Error('Unable to find latest version on GitHub')) - }) + it('silently drops background benign failures to idle and waits for the hourly retry', async () => { + vi.useFakeTimers() + makeBenignCheckFailure('Unable to find latest version on GitHub') const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } const { setupAutoUpdater, checkForUpdates } = await import('./updater') - setupAutoUpdater(mainWindow as never) + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) checkForUpdates() - await vi.waitFor(() => { - const statuses = sendMock.mock.calls - .filter(([channel]) => channel === 'updater:status') - .map(([, status]) => status) - expect(statuses).toContainEqual({ state: 'idle' }) - expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' })) - }) + + await vi.advanceTimersByTimeAsync(0) + + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + + expect(statuses).toContainEqual({ state: 'idle' }) + expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' })) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(THIRTY_SECONDS_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS - THIRTY_SECONDS_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 773a849425a..3f488a729ce 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -206,7 +206,7 @@ describe('updater', () => { expect.objectContaining({ state: 'error', userInitiated: true, - message: expect.stringContaining('GitHub may be temporarily unavailable') + message: expect.stringContaining("Couldn't reach the update server") }) ) }) diff --git a/src/main/updater.ts b/src/main/updater.ts index 72048271cee..c0186a8dae6 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -217,7 +217,7 @@ async function sendCheckFailureStatus(message: string, userInitiated?: boolean): // prefixes "Could not check for updates." and Settings prefixes // "Update check failed.", so the message here only carries the // actionable cause. - sendErrorStatus('GitHub may be temporarily unavailable. Try again in a minute.', true) + sendErrorStatus("Couldn't reach the update server. Try again in a few minutes.", true) } else { sendStatus({ state: 'idle' }) } @@ -284,17 +284,20 @@ async function pinPrereleaseFeed(): Promise { // back to the default /releases/latest/download/ URL. In the "no newer" // case that feed will report the latest stable and compareVersions in the // 'update-available' handler will correctly mark it as not-available. - const newerTag = await fetchNewerReleaseTag(app.getVersion()) + const currentVersion = app.getVersion() + const newerTag = await fetchNewerReleaseTag(currentVersion) + // Why: console.info goes to stdout and is captured by Console.app on macOS + // and by --enable-logging elsewhere. This is the only window we have into + // the updater on a user's machine when something goes wrong (issue: RC user + // not offered newer stable). Cheap to keep, invaluable when triaging. if (newerTag) { - autoUpdater.setFeedURL({ - provider: 'generic', - url: getReleaseDownloadUrl(newerTag) - }) + const url = getReleaseDownloadUrl(newerTag) + console.info(`[updater] prerelease feed pinned: current=${currentVersion} → ${url}`) + autoUpdater.setFeedURL({ provider: 'generic', url }) } else { - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) + const url = 'https://github.com/stablyai/orca/releases/latest/download' + console.info(`[updater] prerelease feed fallback: current=${currentVersion} → ${url}`) + autoUpdater.setFeedURL({ provider: 'generic', url }) } } @@ -509,6 +512,19 @@ export function setupAutoUpdater( autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true + // Why: the only on-machine window we have into electron-updater. Without + // this, an unexpected `update-not-available` (e.g. RC user not offered + // newer stable) is invisible — we can't tell whether the manifest fetch + // got the wrong version, the request failed, or a stale in-flight check + // was deduped. Logs go to main-process stdout, captured on macOS by + // Console.app under the app bundle, and on Win/Linux by --enable-logging. + autoUpdater.logger = { + info: (m: unknown) => console.info('[autoUpdater]', m), + warn: (m: unknown) => console.warn('[autoUpdater]', m), + error: (m: unknown) => console.error('[autoUpdater]', m), + debug: (m: unknown) => console.debug('[autoUpdater]', m) + } as never + // Why: no Windows Authenticode certificate exists for this project. // electron-builder embeds the code-signing publisherName into the app's // bundled app-update.yml at build time. Versions that were incorrectly diff --git a/src/main/usage-worktree-metadata.test.ts b/src/main/usage-worktree-metadata.test.ts new file mode 100644 index 00000000000..0a6221122b5 --- /dev/null +++ b/src/main/usage-worktree-metadata.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { loadKnownUsageWorktreesByRepo } from './usage-worktree-metadata' + +describe('loadKnownUsageWorktreesByRepo', () => { + it('builds usage worktree refs from repo roots and persisted metadata', () => { + const store = { + getAllWorktreeMeta: vi.fn(() => ({ + 'repo-1::/workspace/repo-a-feature': { + displayName: 'Feature A' + }, + 'repo-2::/remote/repo-b-feature': { + displayName: 'Remote feature' + }, + malformed: { + displayName: 'Ignored' + } + })) + } + const repos = [ + { + id: 'repo-1', + path: '/workspace/repo-a', + displayName: 'Repo A' + }, + { + id: 'repo-2', + path: '/remote/repo-b', + displayName: 'Remote Repo', + connectionId: 'ssh-1' + } + ] + + expect(loadKnownUsageWorktreesByRepo(store as never, repos as never)).toEqual( + new Map([ + [ + 'repo-1', + [ + { + worktreeId: 'repo-1::/workspace/repo-a', + path: '/workspace/repo-a', + displayName: 'Repo A' + }, + { + worktreeId: 'repo-1::/workspace/repo-a-feature', + path: '/workspace/repo-a-feature', + displayName: 'Feature A' + } + ] + ] + ]) + ) + expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/usage-worktree-metadata.ts b/src/main/usage-worktree-metadata.ts new file mode 100644 index 00000000000..f08bc98d63b --- /dev/null +++ b/src/main/usage-worktree-metadata.ts @@ -0,0 +1,66 @@ +import { basename } from 'path' +import type { Repo } from '../shared/types' +import type { Store } from './persistence' + +export type UsageWorktreeRef = { + worktreeId: string + path: string + displayName: string +} + +function getDefaultUsageWorktreeLabel(pathValue: string): string { + return basename(pathValue) +} + +function parseKnownWorktreeId(worktreeId: string): { repoId: string; worktreePath: string } | null { + const sepIdx = worktreeId.indexOf('::') + if (sepIdx === -1) { + return null + } + return { + repoId: worktreeId.slice(0, sepIdx), + worktreePath: worktreeId.slice(sepIdx + 2) + } +} + +export function loadKnownUsageWorktreesByRepo( + store: Pick, + repos: Repo[] +): Map { + const localRepos = repos.filter((repo) => !repo.connectionId) + const repoIds = new Set(localRepos.map((repo) => repo.id)) + const worktreesByRepo = new Map() + const seenPathsByRepo = new Map>() + + for (const repo of localRepos) { + worktreesByRepo.set(repo.id, [ + { + worktreeId: `${repo.id}::${repo.path}`, + path: repo.path, + displayName: repo.displayName || getDefaultUsageWorktreeLabel(repo.path) + } + ]) + seenPathsByRepo.set(repo.id, new Set([repo.path])) + } + + // Why: usage scans are background/opt-in analytics. Do not spawn + // `git worktree list` here; it can re-touch macOS protected folders. + for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + const parsed = parseKnownWorktreeId(worktreeId) + if (!parsed || !repoIds.has(parsed.repoId)) { + continue + } + const seenPaths = seenPathsByRepo.get(parsed.repoId) + if (seenPaths?.has(parsed.worktreePath)) { + continue + } + seenPaths?.add(parsed.worktreePath) + worktreesByRepo.get(parsed.repoId)?.push({ + worktreeId, + path: parsed.worktreePath, + displayName: meta.displayName || getDefaultUsageWorktreeLabel(parsed.worktreePath) + }) + } + + return worktreesByRepo +} diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index c5ba117331d..09d9aabbd52 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -1,10 +1,11 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { randomUUID } from 'node:crypto' import { app, clipboard, ipcMain, nativeImage, session } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import type { CreateWorktreeResult } from '../../shared/types' +import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' @@ -23,8 +24,9 @@ import { dismissNudge } from '../updater' import { scheduleHistoryGc } from '../terminal-history' -import { listRepoWorktrees } from '../repo-worktrees' +import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' export function attachMainWindowServices( mainWindow: BrowserWindow, @@ -40,7 +42,8 @@ export function attachMainWindowServices( runtime, getSelectedCodexHomePath, () => store.getSettings(), - prepareClaudeAuth + prepareClaudeAuth, + store ) // Why: the Manage Sessions settings panel (docs/daemon-staleness-ux.md §Phase 1) // uses a narrow `pty:management:*` IPC surface that reads the live @@ -49,20 +52,22 @@ export function attachMainWindowServices( // and ensures the handlers are re-installed on macOS app re-activation when // the main window is recreated. registerDaemonManagementHandlers() - // Why: GC runs on a 10s delay so live worktree enumeration completes first. - // Uses git worktree list (not store.getWorktreeMeta) because untouched - // worktrees have no metadata entries — see design doc §7.6. + // Why: do not enumerate repo paths from background GC. `git worktree list` + // can re-touch protected folders on macOS and trigger folder-access prompts. scheduleHistoryGc(async () => { - const repos = store.getRepos() - const ids = new Set() - for (const repo of repos) { - const worktrees = await listRepoWorktrees(repo) - for (const wt of worktrees) { - ids.add(`${repo.id}::${wt.path}`) - } - } - return ids + return getKnownWorktreeIdsForHistoryGc(store) }) + // Why: warm-reattach gap. + // Daemon-hosted PTYs survive renderer restarts on purpose, so on a fresh + // Orca launch the daemon's `listSessions()` returns sessions that + // `pty:spawn` hasn't re-registered yet. Without this hydration, the + // memory snapshot omits those PTYs and the renderer mislabels their + // workspaces as `· REMOTE` while showing `—` for CPU/Memory. + // `hydrateLocalPtyRegistryAtBoot` is idempotent (no-op after the first + // call), so calling it on every macOS dock re-activation — when this + // function re-runs as the main window is recreated — does not redo the + // git I/O or daemon RPC. + void hydrateLocalPtyRegistryAtBoot(store) registerSshHandlers(store, () => mainWindow, runtime) registerFileDropRelay(mainWindow) setupAutoUpdater(mainWindow, { @@ -199,56 +204,78 @@ function registerRuntimeWindowLifecycle( runtime: OrcaRuntimeService ): void { runtime.attachWindow(mainWindow.id) - runtime.setNotifier({ - worktreesChanged: (repoId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('worktrees:changed', { repoId }) - } - }, - reposChanged: () => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('repos:changed') - } - }, - activateWorktree: (repoId, worktreeId, setup?: CreateWorktreeResult['setup']) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:activateWorktree', { repoId, worktreeId, setup }) - } - }, - createTerminal: (worktreeId, opts) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:createTerminal', { - worktreeId, - command: opts.command, - title: opts.title - }) - } - }, - splitTerminal: (tabId, paneRuntimeId, opts) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:splitTerminal', { - tabId, - paneRuntimeId, - direction: opts.direction, - command: opts.command - }) - } - }, - renameTerminal: (tabId, title) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:renameTerminal', { tabId, title }) - } - }, - focusTerminal: (tabId, worktreeId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:focusTerminal', { tabId, worktreeId }) - } - }, - closeTerminal: (tabId, paneRuntimeId) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('ui:closeTerminal', { tabId, paneRuntimeId }) - } + const send = (channel: string, ...args: unknown[]): void => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, ...args) } + } + runtime.setNotifier({ + worktreesChanged: (repoId) => send('worktrees:changed', { repoId }), + worktreeBaseStatus: (event) => send('worktree:baseStatus', event), + worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), + reposChanged: () => send('repos:changed'), + activateWorktree: ( + repoId, + worktreeId, + setup?: CreateWorktreeResult['setup'], + startup?: WorktreeStartupLaunch + ) => { + send('ui:activateWorktree', { + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}) + }) + }, + createTerminal: (worktreeId, opts) => + send('ui:createTerminal', { worktreeId, command: opts.command, title: opts.title }), + revealTerminalSession: (worktreeId, opts) => + new Promise((resolve, reject) => { + const requestId = randomUUID() + const timer = setTimeout(() => { + ipcMain.removeListener('terminal:tabCreateReply', handler) + reject(new Error('Terminal reveal timed out')) + }, 10_000) + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; tabId?: string; title?: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + return + } + resolve({ tabId: reply.tabId!, title: reply.title }) + } + ipcMain.on('terminal:tabCreateReply', handler) + send('ui:createTerminal', { + requestId, + worktreeId, + ptyId: opts.ptyId, + title: opts.title ?? undefined, + activate: opts.activate !== false + }) + }), + splitTerminal: (tabId, paneRuntimeId, opts) => { + send('ui:splitTerminal', { + tabId, + paneRuntimeId, + direction: opts.direction, + command: opts.command + }) + }, + renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), + focusTerminal: (tabId, worktreeId) => send('ui:focusTerminal', { tabId, worktreeId }), + closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), + sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => + send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), + terminalDriverChanged: (ptyId, driver) => + send('runtime:terminalDriverChanged', { ptyId, driver }) }) // Why: the runtime must fail closed while the renderer graph is being torn // down or rebuilt, otherwise future CLI calls could act on stale terminal diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 56fcf5f9701..f4ba7e6a608 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -1,17 +1,30 @@ /* oxlint-disable max-lines */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { browserWindowMock, openExternalMock, attachGuestPoliciesMock, isMock } = vi.hoisted(() => ({ - browserWindowMock: vi.fn(), - openExternalMock: vi.fn(), - attachGuestPoliciesMock: vi.fn(), - isMock: { dev: false } -})) +const { + browserWindowMock, + openExternalMock, + attachGuestPoliciesMock, + buildFromTemplateMock, + menuPopupMock, + isMock +} = vi.hoisted(() => { + const menuPopupMock = vi.fn() + return { + browserWindowMock: vi.fn(), + openExternalMock: vi.fn(), + attachGuestPoliciesMock: vi.fn(), + buildFromTemplateMock: vi.fn(() => ({ popup: menuPopupMock })), + menuPopupMock, + isMock: { dev: false } + } +}) vi.mock('electron', () => ({ app: { on: vi.fn(), removeListener: vi.fn() }, BrowserWindow: browserWindowMock, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + ipcMain: { on: vi.fn(), removeListener: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() }, + Menu: { buildFromTemplate: buildFromTemplateMock }, nativeTheme: { shouldUseDarkColors: false }, screen: { getPrimaryDisplay: () => ({ workAreaSize: { width: 1440, height: 900 } }) @@ -45,9 +58,13 @@ describe('createMainWindow', () => { browserWindowMock.mockReset() openExternalMock.mockReset() attachGuestPoliciesMock.mockReset() + buildFromTemplateMock.mockClear() + menuPopupMock.mockClear() isMock.dev = false vi.mocked(ipcMain.on).mockReset() vi.mocked(ipcMain.removeListener).mockReset() + vi.mocked(ipcMain.handle).mockReset() + vi.mocked(ipcMain.removeHandler).mockReset() vi.useRealTimers() }) @@ -97,6 +114,10 @@ describe('createMainWindow', () => { expect(browserWindowOptions).toMatchObject({ titleBarStyle: 'hiddenInset' }) + } else if (process.platform === 'win32') { + expect(browserWindowOptions).toMatchObject({ + titleBarStyle: 'hidden' + }) } else { expect(browserWindowOptions.titleBarStyle).toBeUndefined() } @@ -494,7 +515,7 @@ describe('createMainWindow', () => { syncListener?.({} as never, 1.2) if (process.platform === 'darwin') { - expect(browserWindowInstance.setWindowButtonPosition).toHaveBeenCalledWith({ x: 16, y: 18 }) + expect(browserWindowInstance.setWindowButtonPosition).toHaveBeenCalledWith({ x: 16, y: 16 }) return } @@ -752,6 +773,108 @@ describe('createMainWindow', () => { expect(webContents.send).toHaveBeenCalledWith('ui:toggleLeftSidebar') }) + it('shows spellcheck context menu for editable text without relying on markdown focus mirror', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn(), + replaceMisspelling: vi.fn(), + session: { addWordToSpellCheckerDictionary: vi.fn() } + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + windowHandlers['context-menu']( + {} as never, + { + x: 42, + y: 84, + isEditable: true, + spellcheckEnabled: true, + dictionarySuggestions: ['reference'], + misspelledWord: 'refrence' + } as Electron.ContextMenuParams + ) + + expect(buildFromTemplateMock).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ label: 'reference' })]) + ) + expect(menuPopupMock).toHaveBeenCalledWith({ window: browserWindowInstance, x: 42, y: 84 }) + }) + + it('does not read destroyed webContents during closed cleanup', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + let webContentsDestroyed = false + const browserWindowInstance = { + get webContents() { + if (webContentsDestroyed) { + throw new Error('Object has been destroyed') + } + return webContents + }, + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + webContentsDestroyed = true + + // Why: Electron may destroy webContents before BrowserWindow's `closed` + // cleanup runs during updater shutdown. The cleanup must not crash, or + // Squirrel.Mac never reaches the relaunch step. + expect(() => windowHandlers.closed?.()).not.toThrow() + }) + it('resets the markdown editor focus flag on renderer crash, navigation, and destroy', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 675bdcabba9..54402a9247a 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import { app, BrowserWindow, ipcMain, nativeTheme, screen, shell } from 'electron' +import { app, BrowserWindow, ipcMain, Menu, nativeTheme, screen, shell } from 'electron' import { join } from 'path' import { is } from '@electron-toolkit/utils' import icon from '../../../resources/icon.png?asset' @@ -13,6 +13,7 @@ import { } from '../../shared/browser-url' import { resolveWindowShortcutAction } from '../../shared/window-shortcut-policy' import { getMainE2EConfig } from '../e2e-config' +import { buildEditableContextMenuTemplate } from './editable-context-menu' function forceRepaint(window: BrowserWindow): void { if (window.isDestroyed()) { @@ -31,11 +32,11 @@ function forceRepaint(window: BrowserWindow): void { }, 32) } -// Why: the titlebar is 42px (border-box, 1px border-bottom). The visual -// center of the CSS-centered content sits at ~20 CSS px from the top. -// At zoom factor z that becomes 20·z window px. Traffic lights are +// Why: the titlebar is 36px (border-box, 1px border-bottom). The visual +// center of the CSS-centered content sits at ~18 CSS px from the top. +// At zoom factor z that becomes 18·z window px. Traffic lights are // ~12px tall, so we position their top edge at (center − 6). -const TITLEBAR_CSS_CENTER = 20 +const TITLEBAR_CSS_CENTER = 18 const TRAFFIC_LIGHT_RADIUS = 6 const TRAFFIC_LIGHT_X = 16 const MIN_WIDTH = 600 @@ -155,7 +156,17 @@ export function createMainWindow( // conventions (File Explorer, Firefox, etc.). autoHideMenuBar: true, backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : undefined, + // Why: on macOS 'hiddenInset' keeps the native traffic lights positioned + // inside our custom 42px titlebar. On Windows 'hidden' removes the default + // OS title bar (which would otherwise stack on top of our renderer titlebar + // and waste vertical space) while still allowing our renderer to draw its + // own drag region and window controls. + titleBarStyle: + process.platform === 'darwin' + ? 'hiddenInset' + : process.platform === 'win32' + ? 'hidden' + : undefined, // Why: initial position for 1x zoom; syncTrafficLightPosition() adjusts // dynamically when the user changes UI zoom. ...(process.platform === 'darwin' @@ -296,11 +307,13 @@ export function createMainWindow( return } store?.updateUI({ windowMaximized: true }) + mainWindow.webContents.send('window:maximize-changed', true) }) mainWindow.on('unmaximize', () => { if (windowClosing) { return } + mainWindow.webContents.send('window:maximize-changed', false) const bounds = mainWindow.getBounds() // Why: mirror the saveBounds guard — unmaximize during teardown can land // at MIN_WIDTH × MIN_HEIGHT and we must not persist those as the user's @@ -422,6 +435,18 @@ export function createMainWindow( } ipcMain.on(markdownFocusChannel, onMarkdownEditorFocused) + const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { + const template = buildEditableContextMenuTemplate(params, mainWindow.webContents) + if (template.length === 0) { + return + } + // Why: right-click can produce a Chromium context-menu event before our + // renderer focus mirror updates, so trust Electron's editable/spellcheck + // params here instead of gating on markdownEditorFocused. + Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y }) + } + mainWindow.webContents.on('context-menu', onMainContextMenu) + // Why: renderer can't mirror focus state across a crash/reload/close. // Default-deny the carve-out so Cmd+B falls back to sidebar-toggle, which is // the safe behavior when focus context is unknown. Preserves the @@ -514,9 +539,7 @@ export function createMainWindow( // Why: routed through the main process so focus contexts that bypass // the renderer's window-level keydown (contentEditable markdown editor, // browser-guest webContents) still reach the new-workspace composer. - // Forward the target tab so Cmd/Ctrl+Shift+N lands on the - // "Create from…" tab instead of the default quick-create form. - mainWindow.webContents.send('ui:openNewWorkspace', action.tab) + mainWindow.webContents.send('ui:openNewWorkspace') return } @@ -592,6 +615,61 @@ export function createMainWindow( } ipcMain.on(trafficLightChannel, onSyncTrafficLights) + // Why: renderer-drawn window controls on Windows send these to replicate the + // native title bar buttons that 'hidden' titleBarStyle removes. + const minimizeChannel = 'window:minimize' + const onMinimize = (): void => { + if (!mainWindow.isDestroyed()) { + mainWindow.minimize() + } + } + const maximizeChannel = 'window:maximize' + const onMaximize = (): void => { + if (mainWindow.isDestroyed()) { + return + } + if (mainWindow.isMaximized()) { + mainWindow.unmaximize() + } else { + mainWindow.maximize() + } + } + // Why: send window:close-requested directly rather than calling + // mainWindow.close() and letting the 'close' event re-send it. Calling + // mainWindow.close() from within an IPC message handler on Windows can cause + // the 'close' event to misfire (e.preventDefault() doesn't suppress the OS + // close in all Windows configurations). Going straight to the renderer's + // close guard (Terminal.tsx onWindowCloseRequested) keeps the flow identical + // to what happens when confirmWindowClose() ultimately calls mainWindow.close() + // with windowCloseConfirmed = true. + const requestCloseChannel = 'window:request-close' + const onRequestClose = (): void => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('window:close-requested', { isQuitting: false }) + } + } + // Why: the ··· button in the renderer-drawn title bar on Windows pops up + // the application menu at the cursor position, replicating the Alt-key + // reveal that autoHideMenuBar normally provides. + const popupMenuChannel = 'menu:popup' + const onPopupMenu = (): void => { + Menu.getApplicationMenu()?.popup({ window: mainWindow }) + } + // Why: the renderer's WindowControls mounts after ready-to-show, which is + // also when savedMaximized is restored — so window:maximize-changed has + // already fired (or not fired, if maximize() was called pre-mount) before + // the listener attaches. Expose a synchronous getter so the button can + // initialize its icon to match the current state on mount. + const isMaximizedChannel = 'window:isMaximized' + const onIsMaximized = (): boolean => { + return !mainWindow.isDestroyed() && mainWindow.isMaximized() + } + ipcMain.on(minimizeChannel, onMinimize) + ipcMain.on(maximizeChannel, onMaximize) + ipcMain.on(requestCloseChannel, onRequestClose) + ipcMain.on(popupMenuChannel, onPopupMenu) + ipcMain.handle(isMaximizedChannel, onIsMaximized) + ipcMain.on(confirmCloseChannel, onConfirmClose) mainWindow.on('closed', () => { // Why: default-deny the Cmd+B carve-out after the window is gone so a @@ -599,8 +677,17 @@ export function createMainWindow( // with the webContents lifecycle resets above. markdownEditorFocused = false ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights) + ipcMain.removeListener(minimizeChannel, onMinimize) + ipcMain.removeListener(maximizeChannel, onMaximize) + ipcMain.removeListener(requestCloseChannel, onRequestClose) + ipcMain.removeListener(popupMenuChannel, onPopupMenu) + ipcMain.removeHandler(isMaximizedChannel) ipcMain.removeListener(confirmCloseChannel, onConfirmClose) ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused) + // Why: on updater-triggered shutdown, BrowserWindow can emit `closed` + // after its webContents has already been destroyed. The destroyed + // webContents owns its listeners, so do not touch `mainWindow.webContents` + // here or the quit path can crash before Squirrel.Mac relaunches Orca. app.removeListener('before-quit', freezeBoundsOnQuit) }) diff --git a/src/main/window/editable-context-menu.test.ts b/src/main/window/editable-context-menu.test.ts new file mode 100644 index 00000000000..35b59785801 --- /dev/null +++ b/src/main/window/editable-context-menu.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildEditableContextMenuTemplate } from './editable-context-menu' +import { richMarkdownContextMenuCommandChannel } from '../../shared/rich-markdown-context-menu' + +function contextParams( + overrides: Partial = {} +): Electron.ContextMenuParams { + return { + x: 0, + y: 0, + frame: null, + linkURL: '', + linkText: '', + pageURL: '', + frameURL: '', + srcURL: '', + mediaType: 'none', + hasImageContents: false, + isEditable: true, + selectionText: '', + titleText: '', + altText: '', + suggestedFilename: '', + selectionRect: { x: 0, y: 0, width: 0, height: 0 }, + selectionStartOffset: 0, + referrerPolicy: {} as Electron.Referrer, + misspelledWord: 'refrence', + dictionarySuggestions: ['reference', 'reverence'], + frameCharset: 'utf-8', + formControlType: 'none', + spellcheckEnabled: true, + menuSourceType: 'mouse', + mediaFlags: {} as Electron.MediaFlags, + editFlags: {} as Electron.EditFlags, + ...overrides + } +} + +describe('buildEditableContextMenuTemplate', () => { + it('builds spelling suggestion actions for editable markdown text', () => { + const replaceMisspelling = vi.fn() + const addWordToSpellCheckerDictionary = vi.fn() + const send = vi.fn() + const template = buildEditableContextMenuTemplate(contextParams(), { + replaceMisspelling, + send, + session: { addWordToSpellCheckerDictionary } as unknown as Electron.Session + }) + + expect(template.slice(0, 5).map((item) => item.label ?? item.type)).toEqual([ + 'reference', + 'reverence', + 'separator', + 'Add to dictionary', + 'separator' + ]) + + template[0].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + template[3].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + + expect(replaceMisspelling).toHaveBeenCalledWith('reference') + expect(addWordToSpellCheckerDictionary).toHaveBeenCalledWith('refrence') + }) + + it('builds a menu when Chromium reports suggestions even if spellcheckEnabled is false', () => { + const template = buildEditableContextMenuTemplate(contextParams({ spellcheckEnabled: false }), { + replaceMisspelling: vi.fn(), + send: vi.fn(), + session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session + }) + + expect(template.map((item) => item.label ?? item.type)).toContain('reference') + }) + + it('adds useful markdown and native edit actions for rich markdown text', () => { + const send = vi.fn() + const template = buildEditableContextMenuTemplate( + contextParams({ x: 12, y: 34, misspelledWord: '', dictionarySuggestions: [] }), + { + replaceMisspelling: vi.fn(), + send, + session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session + } + ) + + expect(template.map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'Add link', + 'separator', + 'Format', + 'Paragraph', + 'Insert', + 'separator', + 'cut', + 'copy', + 'paste', + 'Paste as plain text', + 'selectAll' + ]) + + template[0].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + expect(send).toHaveBeenCalledWith(richMarkdownContextMenuCommandChannel, { + command: 'add-link', + x: 12, + y: 34 + }) + + const formatMenu = template[2].submenu as Electron.MenuItemConstructorOptions[] + formatMenu[0].click?.( + {} as Electron.MenuItem, + {} as Electron.BrowserWindow, + {} as KeyboardEvent + ) + expect(send).toHaveBeenLastCalledWith(richMarkdownContextMenuCommandChannel, { + command: 'bold', + x: 12, + y: 34 + }) + }) + + it('does not build a menu outside editable misspelled text', () => { + const webContents = { + replaceMisspelling: vi.fn(), + send: vi.fn(), + session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session + } + + expect( + buildEditableContextMenuTemplate(contextParams({ isEditable: false }), webContents) + ).toEqual([]) + expect( + buildEditableContextMenuTemplate( + contextParams({ + formControlType: 'input-text', + misspelledWord: '', + dictionarySuggestions: [] + }), + webContents + ) + ).toEqual([]) + }) + + it('keeps regular text inputs to spelling and native edit actions', () => { + const template = buildEditableContextMenuTemplate( + contextParams({ formControlType: 'input-text' }), + { + replaceMisspelling: vi.fn(), + send: vi.fn(), + session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session + } + ) + + expect(template.map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'reference', + 'reverence', + 'separator', + 'Add to dictionary', + 'separator', + 'cut', + 'copy', + 'paste', + 'Paste as plain text', + 'selectAll' + ]) + }) +}) diff --git a/src/main/window/editable-context-menu.ts b/src/main/window/editable-context-menu.ts new file mode 100644 index 00000000000..f7f60e95f85 --- /dev/null +++ b/src/main/window/editable-context-menu.ts @@ -0,0 +1,127 @@ +import { + richMarkdownContextMenuCommandChannel, + type RichMarkdownContextMenuCommand, + type RichMarkdownContextMenuCommandPayload +} from '../../shared/rich-markdown-context-menu' + +type EditableContextMenuWebContents = Pick< + Electron.WebContents, + 'replaceMisspelling' | 'send' | 'session' +> + +function markdownCommandItem( + label: string, + command: RichMarkdownContextMenuCommand, + webContents: EditableContextMenuWebContents, + point: { x: number; y: number } +): Electron.MenuItemConstructorOptions { + return { + label, + click: () => { + const payload: RichMarkdownContextMenuCommandPayload = { command, ...point } + webContents.send(richMarkdownContextMenuCommandChannel, payload) + } + } +} + +function buildMarkdownMenuTemplate( + webContents: EditableContextMenuWebContents, + point: { x: number; y: number } +): Electron.MenuItemConstructorOptions[] { + return [ + markdownCommandItem('Add link', 'add-link', webContents, point), + { type: 'separator' }, + { + label: 'Format', + submenu: [ + markdownCommandItem('Bold', 'bold', webContents, point), + markdownCommandItem('Italic', 'italic', webContents, point), + markdownCommandItem('Strike', 'strike', webContents, point), + markdownCommandItem('Inline code', 'inline-code', webContents, point), + markdownCommandItem('Code block', 'code-block', webContents, point), + markdownCommandItem('Quote', 'blockquote', webContents, point) + ] + }, + { + label: 'Paragraph', + submenu: [ + markdownCommandItem('Body text', 'paragraph', webContents, point), + markdownCommandItem('Heading 1', 'heading-1', webContents, point), + markdownCommandItem('Heading 2', 'heading-2', webContents, point), + markdownCommandItem('Heading 3', 'heading-3', webContents, point), + { type: 'separator' }, + markdownCommandItem('Bullet list', 'bullet-list', webContents, point), + markdownCommandItem('Numbered list', 'ordered-list', webContents, point), + markdownCommandItem('Checklist', 'task-list', webContents, point) + ] + }, + { + label: 'Insert', + submenu: [ + markdownCommandItem('Link', 'add-link', webContents, point), + markdownCommandItem('Image', 'image', webContents, point), + markdownCommandItem('Divider', 'divider', webContents, point), + markdownCommandItem('Code block', 'code-block', webContents, point) + ] + }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'pasteAndMatchStyle', label: 'Paste as plain text' }, + { role: 'selectAll' } + ] +} + +function buildNativeEditMenuTemplate(): Electron.MenuItemConstructorOptions[] { + return [ + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'pasteAndMatchStyle', label: 'Paste as plain text' }, + { role: 'selectAll' } + ] +} + +export function buildEditableContextMenuTemplate( + params: Electron.ContextMenuParams, + webContents: EditableContextMenuWebContents +): Electron.MenuItemConstructorOptions[] { + if (!params.isEditable) { + return [] + } + + const suggestions = params.dictionarySuggestions.slice(0, 5) + const isRichMarkdownSurface = params.formControlType === 'none' + if (!isRichMarkdownSurface && suggestions.length === 0 && !params.misspelledWord) { + return [] + } + + const template: Electron.MenuItemConstructorOptions[] = suggestions.map((suggestion) => ({ + label: suggestion, + click: () => webContents.replaceMisspelling(suggestion) + })) + + if (params.misspelledWord) { + if (template.length > 0) { + template.push({ type: 'separator' }) + } + template.push({ + label: 'Add to dictionary', + click: () => { + webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) + } + }) + } + + if (template.length > 0) { + template.push({ type: 'separator' }) + } + template.push( + ...(isRichMarkdownSurface + ? buildMarkdownMenuTemplate(webContents, { x: params.x, y: params.y }) + : buildNativeEditMenuTemplate()) + ) + + return template +} diff --git a/src/main/window/history-gc-worktree-ids.test.ts b/src/main/window/history-gc-worktree-ids.test.ts new file mode 100644 index 00000000000..1aca7d585b0 --- /dev/null +++ b/src/main/window/history-gc-worktree-ids.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' +import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' + +describe('getKnownWorktreeIdsForHistoryGc', () => { + it('uses persisted metadata keys without probing repo paths', () => { + const store = { + getAllWorktreeMeta: vi.fn(() => ({ + 'repo-1::/worktree-a': {}, + 'repo-2::/worktree-b': {} + })) + } + + expect(getKnownWorktreeIdsForHistoryGc(store as never)).toEqual( + new Set(['repo-1::/worktree-a', 'repo-2::/worktree-b']) + ) + expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/window/history-gc-worktree-ids.ts b/src/main/window/history-gc-worktree-ids.ts new file mode 100644 index 00000000000..beec2f9cfc1 --- /dev/null +++ b/src/main/window/history-gc-worktree-ids.ts @@ -0,0 +1,7 @@ +import type { Store } from '../persistence' + +export function getKnownWorktreeIdsForHistoryGc( + store: Pick +): Set { + return new Set(Object.keys(store.getAllWorktreeMeta())) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 75c661e851b..d78e6679904 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -11,7 +11,7 @@ import type { CodexRateLimitAccountsState, CreateWorktreeArgs, CreateWorktreeResult, - CustomSidekick, + CustomPet, DirEntry, FsChangedPayload, GhosttyImportPreview, @@ -19,7 +19,8 @@ import type { GitBranchCompareResult, GitConflictOperation, GitDiffResult, - GitStatusEntry, + GitStatusResult, + GitUpstreamStatus, GitHubAssignableUser, GitHubPRFile, GitHubPRFileContents, @@ -41,14 +42,20 @@ import type { LinearTeam, MarkdownDocument, GitHubIssueUpdate, + GetRateLimitResult, NotificationDispatchRequest, NotificationDispatchResult, + NotificationPermissionStatusResult, + NotificationSoundResult, + OnboardingState, OrcaHooks, + PathSource, PersistedUIState, PRCheckDetail, PRComment, PRInfo, Repo, + ShellHydrationFailureReason, SparsePreset, SearchOptions, SearchResult, @@ -56,10 +63,41 @@ import type { MemorySnapshot, UpdateStatus, Worktree, + WorktreeBaseStatusEvent, WorktreeMeta, + WorktreeRemoteBranchConflictEvent, WorktreeSetupLaunch, + WorktreeStartupLaunch, WorkspaceSessionState } from '../shared/types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefArgs, + ResolveProjectRefResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../shared/github-project-types' +import type { RichMarkdownContextMenuCommandPayload } from '../shared/rich-markdown-context-menu' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -84,13 +122,18 @@ import type { ElectronAPI } from '@electron-toolkit/preload' import type { CliInstallStatus } from '../shared/cli-install-types' import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' -import type { AgentStatusState } from '../shared/agent-status-types' +import type { AgentStatusIpcPayload } from '../shared/agent-status-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, DeveloperPermissionState } from '../shared/developer-permissions-types' +import type { + ComputerUsePermissionId, + ComputerUsePermissionSetupResult, + ComputerUsePermissionStatusResult +} from '../shared/computer-use-permissions-types' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, @@ -102,6 +145,7 @@ import type { ClaudeUsageSummary } from '../shared/claude-usage-types' import type { RateLimitState } from '../shared/rate-limit-types' +import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { SshConnectionState, SshTarget, @@ -118,12 +162,15 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../shared/codex-usage-types' +import type { TelemetryConsentState } from '../shared/telemetry-consent-types' +import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' export type BrowserApi = { registerGuest: (args: { browserPageId: string workspaceId: string worktreeId: string + sessionProfileId?: string | null webContentsId: number }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise @@ -150,6 +197,9 @@ export type BrowserApi = { callback: (event: { browserPageId: string; url: string; title: string }) => void ) => () => void onActivateView: (callback: (data: { worktreeId: string }) => void) => () => void + onPaneFocus: ( + callback: (data: { worktreeId: string | null; browserPageId: string }) => void + ) => () => void onOpenLinkInOrcaTab: ( callback: (event: { browserPageId: string; url: string }) => void ) => () => void @@ -207,6 +257,15 @@ export type RefreshAgentsResult = { agents: string[] addedPathSegments: string[] shellHydrationOk: boolean + /** Why: drives the agent_picks `on_path:false` triage in dashboard 1562016 + * (insight A). `'shell_hydrate'` = detection saw the user's full shell PATH; + * `'sync_seed_only'` = hydration failed and detection ran against the + * seed list from `patchPackagedProcessPath`. */ + pathSource: PathSource + /** Why: classified hydration outcome. `'none'` on success; one of the failure + * modes when `shellHydrationOk` is false. Typed off the shared alias so + * schema/main/preload/renderer stay in lockstep. */ + pathFailureReason: ShellHydrationFailureReason } export type PreflightApi = { @@ -307,17 +366,10 @@ export type CodexUsageApi = { }) => Promise } -export type AppRuntimeFlags = { - agentDashboardEnabledAtStartup: boolean -} - export type AppApi = { - /** Returns flags about the main-process state that was set at startup. - * The renderer uses this to show a "restart required" banner when the user - * toggles a setting that only applies across a full relaunch. */ - getRuntimeFlags: () => Promise /** Relaunches the app via Electron's app.relaunch() + app.exit(0). Used - * by the "Restart now" button on the Experimental settings pane. */ + * by settings panes that need a full restart to apply changes (e.g. the + * terminal-window blur setting in TerminalWindowSection). */ relaunch: () => Promise /** Returns the macOS `AppleCurrentKeyboardLayoutInputSourceID` when * available (e.g. `com.apple.keylayout.PolishPro`). Used by the @@ -325,6 +377,8 @@ export type AppApi = { * US QWERTY but whose Option layer composes characters (issue #1205). * Returns null on non-Darwin platforms or when the defaults read fails. */ getKeyboardInputSourceId: () => Promise + /** Updates the macOS Dock unread badge. No-op on Windows/Linux. */ + setUnreadDockBadgeCount: (count: number) => Promise } export type PreloadApi = { @@ -340,6 +394,7 @@ export type PreloadApi = { kind?: 'git' | 'folder' }) => Promise<{ repo: Repo } | { error: string }> remove: (args: { repoId: string }) => Promise + reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> update: (args: { repoId: string updates: Partial< @@ -365,6 +420,12 @@ export type PreloadApi = { displayName?: string kind?: 'git' | 'folder' }) => Promise<{ repo: Repo } | { error: string }> + // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. + create: (args: { + parentPath: string + name: string + kind: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void getGitUsername: (args: { repoId: string }) => Promise getBaseRefDefault: (args: { repoId: string }) => Promise @@ -396,6 +457,10 @@ export type PreloadApi = { updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise persistSortOrder: (args: { orderedIds: string[] }) => Promise onChanged: (callback: (data: { repoId: string }) => void) => () => void + onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void + onRemoteBranchConflict: ( + callback: (data: WorktreeRemoteBranchConflictEvent) => void + ) => () => void } pty: { spawn: (opts: { @@ -411,6 +476,16 @@ export type PreloadApi = { // Preserved from the deleted index.d.ts PtyApi duplicate during the // single-source-of-truth collapse (see docs/preload-typecheck-hole.md §1). shellOverride?: string + // Why: closes the SIGKILL race documented in INVESTIGATION.md — main + // sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before + // pty:spawn returns. Only the renderer's daemon-host path threads these. + tabId?: string + leafId?: string + // Why: telemetry-plan.md§Agent launch semantics — main emits + // `agent_started` only after the PTY/session is created successfully, + // so the renderer threads the launch metadata through this field and + // the IPC handler fires the event from the spawn-success branch. + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ id: string snapshot?: string @@ -424,8 +499,9 @@ export type PreloadApi = { }> write: (id: string, data: string) => void resize: (id: string, cols: number, rows: number) => void + reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void - kill: (id: string) => Promise + kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise @@ -434,11 +510,26 @@ export type PreloadApi = { onData: (callback: (data: { id: string; data: string }) => void) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void + onSerializeBufferRequest: ( + callback: (data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + }) => void + ) => () => void + sendSerializedBuffer: ( + requestId: string, + snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + ) => void + declarePendingPaneSerializer: (paneKey: string) => Promise + settlePaneSerializer: (paneKey: string, gen: number) => Promise + clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise management: PtyManagementApi } feedback: { submit: (args: { feedback: string + submitAnonymously?: boolean githubLogin: string | null githubEmail: string | null }) => Promise<{ ok: true } | { ok: false; status: number | null; error: string }> @@ -447,13 +538,24 @@ export type PreloadApi = { gh: { viewer: () => Promise repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null> - prForBranch: (args: { repoPath: string; branch: string }) => Promise + prForBranch: (args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + }) => Promise issue: (args: { repoPath: string; number: number }) => Promise workItem: (args: { repoPath: string number: number type?: 'issue' | 'pr' }) => Promise | null> + workItemByOwnerRepo: (args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + }) => Promise | null> workItemDetails: (args: { repoPath: string number: number @@ -512,6 +614,12 @@ export type PreloadApi = { repoPath: string number: number body: string + /** Why: GitHub stores PR conversation comments under `/issues/N/comments` + * too, so the IPC and `gh` call paths are identical. The renderer cache + * key is keyed by the drawer's `type`, so callers pass it through to + * scope the cross-window invalidation broadcast correctly and avoid + * evicting an unrelated PR/issue that happens to share the number. */ + type?: 'issue' | 'pr' }) => Promise addPRReviewCommentReply: (args: { repoPath: string @@ -525,8 +633,61 @@ export type PreloadApi = { addPRReviewComment: (args: GitHubPRReviewCommentInput) => Promise listLabels: (args: { repoPath: string }) => Promise listAssignableUsers: (args: { repoPath: string }) => Promise + /** + * Subscribe to local-mutation broadcasts. Used by the work-item-drawer + * cache to invalidate entries across windows after a successful mutation. + * Returns an unsubscribe function. + */ + onWorkItemMutated: ( + callback: (payload: { repoPath: string; type: 'issue' | 'pr'; number: number }) => void + ) => () => void checkOrcaStarred: () => Promise starOrca: () => Promise + /** + * GitHub API rate-limit snapshot. Does NOT consume quota (the + * `rate_limit` endpoint is exempt). Cached 30s server-side — pass + * `force: true` to bust after a known-expensive op. + */ + rateLimit: (args?: { force?: boolean }) => Promise + /** + * Probe `gh auth status` and the Electron process env to explain + * why ProjectV2 calls are failing with scope_missing. Surfaces the + * common gotcha where `GITHUB_TOKEN` is exported in the user's + * shell and silently shadows the keyring credential — in that case + * `gh auth refresh` is a no-op and the UI must say so. + */ + diagnoseAuth: () => Promise + // ── ProjectV2 (GitHub Projects) ───────────────────────────────── + listAccessibleProjects: () => Promise + resolveProjectRef: (args: ResolveProjectRefArgs) => Promise + listProjectViews: (args: ListProjectViewsArgs) => Promise + getProjectViewTable: (args: GetProjectViewTableArgs) => Promise + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ) => Promise + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ) => Promise + clearProjectItemField: (args: ClearProjectItemFieldArgs) => Promise + updateIssueBySlug: (args: UpdateIssueBySlugArgs) => Promise + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ) => Promise + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ) => Promise + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ) => Promise + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ) => Promise + listLabelsBySlug: (args: ListLabelsBySlugArgs) => Promise + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ) => Promise + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise + updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs) => Promise } linear: { connect: (args: { @@ -576,6 +737,19 @@ export type PreloadApi = { /** Flip the persisted opt-in preference. Subject to a per-session * consent-mutation rate limit on the main side (≤5/session). */ telemetrySetOptIn: (optedIn: boolean) => Promise + /** Read-only view of effective consent state, including the reason if + * disabled (env var / user opt-out / CI / pending banner). Used by the + * Privacy pane to render the correct "blocked by X" helper text — env + * vars are main-side state the renderer cannot read directly. */ + telemetryGetConsentState: () => Promise + /** Banner ✕ — persist `optedIn = true` silently, emit nothing. Deliberately + * a separate channel from `telemetrySetOptIn` because main's `via` + * derivation on that channel would tag this path as `first_launch_banner` + * and fire `telemetry_opted_in`, which the ✕-as-silent-acknowledge + * semantics forbid (the user did not explicitly opt in, they declined to + * intervene). Subject to the same per-session consent-mutation rate + * limit as `telemetrySetOptIn`. */ + telemetryAcknowledgeBanner: () => Promise settings: { get: () => Promise set: (args: Partial) => Promise @@ -611,16 +785,39 @@ export type PreloadApi = { geminiStatus: () => Promise cursorStatus: () => Promise } + agentTrust: { + markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise + } preflight: PreflightApi notifications: { dispatch: (args: NotificationDispatchRequest) => Promise openSystemSettings: () => Promise + getPermissionStatus: () => Promise + requestPermission: () => Promise + playSound: (options?: { force?: boolean }) => Promise + } + onboarding: { + get: () => Promise + // Why: main-process `updateOnboarding` merges checklist field-by-field, so + // callers can pass a partial checklist (e.g. just `{ addedRepo: true }`) + // without re-supplying every flag. + update: ( + updates: Partial> & { + checklist?: Partial + } + ) => Promise } developerPermissions: { getStatus: () => Promise request: (args: { id: DeveloperPermissionId }) => Promise openSettings: (args: { id: DeveloperPermissionId }) => Promise } + computerUsePermissions: { + getStatus: () => Promise + openSetup: (args?: { + id?: ComputerUsePermissionId + }) => Promise + } shell: { openPath: (path: string) => Promise openUrl: (url: string) => Promise @@ -629,13 +826,15 @@ export type PreloadApi = { pathExists: (path: string) => Promise pickAttachment: () => Promise pickImage: () => Promise + pickAudio: () => Promise pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise } - sidekick: { - import: () => Promise - read: (id: string, fileName: string) => Promise - delete: (id: string, fileName: string) => Promise + pet: { + import: () => Promise + importPetBundle: () => Promise + read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise + delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise } browser: BrowserApi hooks: { @@ -759,10 +958,7 @@ export type PreloadApi = { onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void } git: { - status: (args: { - worktreePath: string - connectionId?: string - }) => Promise<{ entries: GitStatusEntry[] }> + status: (args: { worktreePath: string; connectionId?: string }) => Promise conflictOperation: (args: { worktreePath: string connectionId?: string @@ -779,6 +975,17 @@ export type PreloadApi = { baseRef: string connectionId?: string }) => Promise + upstreamStatus: (args: { + worktreePath: string + connectionId?: string + }) => Promise + fetch: (args: { worktreePath: string; connectionId?: string }) => Promise + push: (args: { + worktreePath: string + publish?: boolean + connectionId?: string + }) => Promise + pull: (args: { worktreePath: string; connectionId?: string }) => Promise branchDiff: (args: { worktreePath: string compare: { @@ -791,6 +998,11 @@ export type PreloadApi = { oldPath?: string connectionId?: string }) => Promise + commit: (args: { + worktreePath: string + message: string + connectionId?: string + }) => Promise<{ success: boolean; error?: string }> stage: (args: { worktreePath: string filePath: string @@ -831,14 +1043,23 @@ export type PreloadApi = { onToggleRightSidebar: (callback: () => void) => () => void onToggleWorktreePalette: (callback: () => void) => () => void onOpenQuickOpen: (callback: () => void) => () => void - onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void) => () => void + onOpenNewWorkspace: (callback: () => void) => () => void onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void onNewBrowserTab: (callback: () => void) => () => void onRequestTabCreate: ( - callback: (data: { requestId: string; url: string; worktreeId?: string }) => void + callback: (data: { + requestId: string + url: string + worktreeId?: string + sessionProfileId?: string + }) => void ) => () => void replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void + onRequestTabSetProfile: ( + callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void + ) => () => void + replyTabSetProfile: (reply: { requestId: string; error?: string }) => void onRequestTabClose: ( callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void ) => () => void @@ -850,14 +1071,27 @@ export type PreloadApi = { onHardReloadBrowserPage: (callback: () => void) => () => void onCloseActiveTab: (callback: () => void) => () => void onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void + onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void) => () => void onSwitchTerminalTab: (callback: (direction: 1 | -1) => void) => () => void onToggleStatusBar: (callback: () => void) => () => void onExportPdfRequested: (callback: () => void) => () => void onActivateWorktree: ( - callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void + callback: (data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: WorktreeStartupLaunch + }) => void ) => () => void onCreateTerminal: ( - callback: (data: { worktreeId: string; command?: string; title?: string }) => void + callback: (data: { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + }) => void ) => () => void onRequestTerminalCreate: ( callback: (data: { @@ -888,6 +1122,7 @@ export type PreloadApi = { onCloseTerminal: ( callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void + onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise saveClipboardImageAsTempFile: () => Promise @@ -906,13 +1141,40 @@ export type PreloadApi = { setZoomLevel: (level: number) => void syncTrafficLights: (zoomFactor: number) => void setMarkdownEditorFocused: (focused: boolean) => void + onRichMarkdownContextCommand: ( + callback: (payload: RichMarkdownContextMenuCommandPayload) => void + ) => () => void onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void + minimize: () => void + maximize: () => void + isMaximized: () => Promise + onMaximizeChanged: (callback: (isMaximized: boolean) => void) => () => void + requestClose: () => void + popupMenu: () => void onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void) => () => void confirmWindowClose: () => void } runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise getStatus: () => Promise + getTerminalFitOverrides: () => Promise< + { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + > + restoreTerminalFit: (ptyId: string) => Promise<{ restored: boolean }> + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ) => () => void + onTerminalDriverChanged: ( + callback: (event: { + ptyId: string + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + }) => void + ) => () => void } rateLimits: { get: () => Promise @@ -934,6 +1196,7 @@ export type PreloadApi = { connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise getState: (args: { targetId: string }) => Promise + needsPassphrasePrompt: (args: { targetId: string }) => Promise testConnection: (args: { targetId: string }) => Promise<{ success: boolean; error?: string; state?: SshConnectionState }> @@ -987,20 +1250,32 @@ export type PreloadApi = { } agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ - onSet: ( - callback: (data: { - paneKey: string - tabId?: string - worktreeId?: string - state: AgentStatusState - prompt?: string - agentType?: string - toolName?: string - toolInput?: string - lastAssistantMessage?: string - interrupted?: boolean - }) => void - ) => () => void + onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void + /** Return the current main-process hook cache after renderer hydration. */ + getSnapshot: () => Promise + /** Drop a paneKey from the main-process hook cache and the on-disk + * last-status file. Fire-and-forget. */ + drop: (paneKey: string) => void + } + mobile: { + listNetworkInterfaces: () => Promise<{ + interfaces: { name: string; address: string }[] + }> + getPairingQR: (args?: { address?: string; rotate?: boolean }) => Promise< + | { available: false } + | { + available: true + qrDataUrl: string + pairingUrl: string + endpoint: string + deviceId: string + } + > + listDevices: () => Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> + revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }> + isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> } } diff --git a/src/preload/index.ts b/src/preload/index.ts index bb52c07c437..9dec589e101 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,33 +4,76 @@ review and type drift checks easier than scattering these bindings across module import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' -import type { AppRuntimeFlags } from './api-types' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { BaseRefDefaultResult, BrowserViewportOverride, CreateWorktreeArgs, - CustomSidekick, + CustomPet, FsChangedPayload, + GetRateLimitResult, GitHubAssignableUser, GitHubCommentResult, GitHubWorkItem, + GitUpstreamStatus, GhosttyImportPreview, ListWorkItemsResult, MemorySnapshot, NotificationDispatchResult, - SearchResult + NotificationPermissionStatusResult, + NotificationSoundDataResult, + NotificationSoundPathResult, + NotificationSoundResult, + OnboardingState, + SearchResult, + WorktreeBaseStatusEvent, + WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' import type { RateLimitState } from '../shared/rate-limit-types' +import type { GhAuthDiagnostic } from '../shared/github-auth-types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefArgs, + ResolveProjectRefResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../shared/github-project-types' +import { + richMarkdownContextMenuCommandChannel, + type RichMarkdownContextMenuCommandPayload +} from '../shared/rich-markdown-context-menu' import type { SshConnectionState, SshTarget, PortForwardEntry, DetectedPort } from '../shared/ssh-types' -import type { AgentStatusState } from '../shared/agent-status-types' +import type { AgentStatusIpcPayload } from '../shared/agent-status-types' +import type { TelemetryConsentState } from '../shared/telemetry-consent-types' +import type { RefreshAgentsResult } from './api-types' +import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, type EditorSaveDirtyFilesDetail @@ -50,6 +93,29 @@ type NativeDropResolution = // falling back to 'editor' — fail-closed behavior per design §7.1. | { target: 'rejected' } +// Why: one shared HTMLAudioElement per sound file, restarted from t=0 on each +// play, with an in-flight guard that drops new plays while the sound is still +// ringing. This mirrors VS Code's AccessibilitySignalService and GNOME's +// libcanberra: a burst of triggers self-dedupes by the sound's own duration +// (no magic time constant), while distinct sounds are still allowed to overlap. +// We also cache the decoded blob URL by path so we don't re-read 10MB from +// disk and re-transfer it over IPC on every notification. +let cachedNotificationSound: { + path: string + blobUrl: string + audio: HTMLAudioElement +} | null = null +let isNotificationSoundPlaying = false + +function disposeCachedNotificationSound(): void { + if (cachedNotificationSound) { + cachedNotificationSound.audio.pause() + cachedNotificationSound.audio.src = '' + URL.revokeObjectURL(cachedNotificationSound.blobUrl) + cachedNotificationSound = null + } +} + /** * Walk the composed event path to classify which UI surface the native OS drop * landed on, and — for file-explorer drops — extract the nearest destination @@ -180,7 +246,6 @@ document.addEventListener( // Custom APIs for renderer const api = { app: { - getRuntimeFlags: (): Promise => ipcRenderer.invoke('app:getRuntimeFlags'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so // the renderer's keyboard-layout probe can distinguish Polish Pro / US @@ -188,7 +253,9 @@ const api = { // src/renderer/src/lib/keyboard-layout/input-source-id.ts, issue #1205). // Returns null on non-Darwin or when the defaults read fails. getKeyboardInputSourceId: (): Promise => - ipcRenderer.invoke('app:getKeyboardInputSourceId') + ipcRenderer.invoke('app:getKeyboardInputSourceId'), + setUnreadDockBadgeCount: (count: number): Promise => + ipcRenderer.invoke('app:setUnreadDockBadgeCount', count) }, wsl: { @@ -212,8 +279,17 @@ const api = { kind?: 'git' | 'folder' }): Promise => ipcRenderer.invoke('repos:addRemote', args), + create: (args: { + parentPath: string + name: string + kind: 'git' | 'folder' + }): Promise => ipcRenderer.invoke('repos:create', args), + remove: (args: { repoId: string }): Promise => ipcRenderer.invoke('repos:remove', args), + reorder: (args: { orderedIds: string[] }): Promise<{ status: 'applied' | 'rejected' }> => + ipcRenderer.invoke('repos:reorder', args), + update: (args: { repoId: string; updates: Record }): Promise => ipcRenderer.invoke('repos:update', args), @@ -308,6 +384,24 @@ const api = { callback(data) ipcRenderer.on('worktrees:changed', listener) return () => ipcRenderer.removeListener('worktrees:changed', listener) + }, + + onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) => + callback(data) + ipcRenderer.on('worktree:baseStatus', listener) + return () => ipcRenderer.removeListener('worktree:baseStatus', listener) + }, + + onRemoteBranchConflict: ( + callback: (data: WorktreeRemoteBranchConflictEvent) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: WorktreeRemoteBranchConflictEvent + ) => callback(data) + ipcRenderer.on('worktree:remoteBranchConflict', listener) + return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener) } }, @@ -322,6 +416,18 @@ const api = { worktreeId?: string sessionId?: string shellOverride?: string + // Why: closes the SIGKILL race documented in INVESTIGATION.md by + // letting main patch + sync-flush the (worktreeId, tabId, leafId → + // ptyId) binding before pty:spawn returns. Only the renderer's + // user-typing-Ctrl+T daemon-host path threads these. + tabId?: string + leafId?: string + // Why: telemetry-plan.md§Agent launch semantics — main fires + // `agent_started` only after the spawn succeeds. The renderer is the + // source of truth for the launch metadata; main is the source of + // truth for whether the launch happened. Loose typing here on + // purpose: validation lives at the main-side schema validator. + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }): Promise<{ id: string snapshot?: string @@ -342,6 +448,15 @@ const api = { ipcRenderer.send('pty:resize', { id, cols, rows }) }, + /** Why: measurement-only sibling of resize. Fires when a desktop pane + * container measures real geometry (e.g. previously hidden tab becomes + * visible) so the runtime's restore-target baseline can stay fresh + * even while a mobile-fit override blocks pty:resize. Never resizes + * the PTY. See docs/mobile-fit-hold.md. */ + reportGeometry: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:reportGeometry', { id, cols, rows }) + }, + signal: (id: string, signal: string): void => { ipcRenderer.send('pty:signal', { id, signal }) }, @@ -350,7 +465,8 @@ const api = { ipcRenderer.send('pty:ackColdRestore', { id }) }, - kill: (id: string): Promise => ipcRenderer.invoke('pty:kill', { id }), + kill: (id: string, opts?: { keepHistory?: boolean }): Promise => + ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> => ipcRenderer.invoke('pty:listSessions'), @@ -389,6 +505,46 @@ const api = { return () => ipcRenderer.removeListener('pty:exit', listener) }, + onSerializeBufferRequest: ( + callback: (data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } + ) => callback(data) + ipcRenderer.on('pty:serializeBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) + }, + + sendSerializedBuffer: ( + requestId: string, + snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + ): void => { + ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) + }, + + // Why: pre-signal handshake — renderer declares it will own the serializer + // for `paneKey` BEFORE issuing pty:spawn so the cooperation gate in main + // can suppress the daemon-snapshot seed. Returns a generation token that + // the renderer must echo on settle/clear so paneKey-reuse during teardown + // cannot defeat the pre-signal. See docs/mobile-prefer-renderer-scrollback.md. + declarePendingPaneSerializer: (paneKey: string): Promise => + ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), + + settlePaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), + + clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + management: { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), @@ -400,6 +556,7 @@ const api = { feedback: { submit: (args: { feedback: string + submitAnonymously?: boolean githubLogin: string | null githubEmail: string | null }): Promise<{ ok: true } | { ok: false; status: number | null; error: string }> => @@ -421,8 +578,11 @@ const api = { repoSlug: (args: { repoPath: string }): Promise => ipcRenderer.invoke('gh:repoSlug', args), - prForBranch: (args: { repoPath: string; branch: string }): Promise => - ipcRenderer.invoke('gh:prForBranch', args), + prForBranch: (args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + }): Promise => ipcRenderer.invoke('gh:prForBranch', args), issue: (args: { repoPath: string; number: number }): Promise => ipcRenderer.invoke('gh:issue', args), @@ -433,6 +593,14 @@ const api = { type?: 'issue' | 'pr' }): Promise => ipcRenderer.invoke('gh:workItem', args), + workItemByOwnerRepo: (args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + workItemDetails: (args: { repoPath: string number: number @@ -513,6 +681,7 @@ const api = { repoPath: string number: number body: string + type?: 'issue' | 'pr' }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { @@ -541,8 +710,81 @@ const api = { listAssignableUsers: (args: { repoPath: string }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), + // Why: every renderer subscribes to local mutation broadcasts so each + // window's work-item-details cache invalidates the affected entry. The + // event fires after a successful mutation in any window — see + // src/main/ipc/github.ts broadcastWorkItemMutated. + onWorkItemMutated: ( + callback: (payload: { repoPath: string; type: 'issue' | 'pr'; number: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { repoPath: string; type: 'issue' | 'pr'; number: number } + ): void => callback(payload) + ipcRenderer.on('gh:workItemMutated', listener) + return () => ipcRenderer.removeListener('gh:workItemMutated', listener) + }, + checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca') + starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca'), + + // Why: rate_limit is exempt from rate-limit accounting, but we still pass + // `force` through so callers can bust the 30s in-process cache after a + // known-expensive op (e.g. after ProjectPicker discovery). + rateLimit: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('gh:rateLimit', args), + + diagnoseAuth: (): Promise => ipcRenderer.invoke('gh:diagnoseAuth'), + + // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── + listAccessibleProjects: (): Promise => + ipcRenderer.invoke('gh:listAccessibleProjects'), + resolveProjectRef: (args: ResolveProjectRefArgs): Promise => + ipcRenderer.invoke('gh:resolveProjectRef', args), + listProjectViews: (args: ListProjectViewsArgs): Promise => + ipcRenderer.invoke('gh:listProjectViews', args), + getProjectViewTable: (args: GetProjectViewTableArgs): Promise => + ipcRenderer.invoke('gh:getProjectViewTable', args), + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ): Promise => + ipcRenderer.invoke('gh:updateProjectItemField', args), + clearProjectItemField: ( + args: ClearProjectItemFieldArgs + ): Promise => ipcRenderer.invoke('gh:clearProjectItemField', args), + updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueBySlug', args), + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updatePullRequestBySlug', args), + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:addIssueCommentBySlug', args), + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), + listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => + ipcRenderer.invoke('gh:listLabelsBySlug', args), + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => + ipcRenderer.invoke('gh:listIssueTypesBySlug', args), + updateIssueTypeBySlug: ( + args: UpdateIssueTypeBySlugArgs + ): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) }, linear: { @@ -624,6 +866,10 @@ const api = { ipcRenderer.invoke('telemetry:track', name, props), telemetrySetOptIn: (optedIn: boolean): Promise => ipcRenderer.invoke('telemetry:setOptIn', optedIn), + telemetryAcknowledgeBanner: (): Promise => + ipcRenderer.invoke('telemetry:acknowledgeBanner'), + telemetryGetConsentState: (): Promise => + ipcRenderer.invoke('telemetry:getConsentState'), settings: { get: (): Promise => ipcRenderer.invoke('settings:get'), @@ -685,6 +931,11 @@ const api = { ipcRenderer.invoke('agentHooks:cursorStatus') }, + agentTrust: { + markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }): Promise => + ipcRenderer.invoke('agentTrust:markTrusted', args) + }, + preflight: { check: (args?: { force?: boolean @@ -694,11 +945,8 @@ const api = { linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), detectAgents: (): Promise => ipcRenderer.invoke('preflight:detectAgents'), - refreshAgents: (): Promise<{ - agents: string[] - addedPathSegments: string[] - shellHydrationOk: boolean - }> => ipcRenderer.invoke('preflight:refreshAgents'), + refreshAgents: (): Promise => + ipcRenderer.invoke('preflight:refreshAgents'), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args) }, @@ -706,7 +954,79 @@ const api = { notifications: { dispatch: (args: Record): Promise => ipcRenderer.invoke('notifications:dispatch', args), - openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings') + openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), + getPermissionStatus: (): Promise => + ipcRenderer.invoke('notifications:getPermissionStatus'), + requestPermission: (): Promise => + ipcRenderer.invoke('notifications:requestPermission'), + playSound: async (options?: { force?: boolean }): Promise => { + try { + // Why: drop replays while the sound is still ringing. The "test" + // button bypasses with force so the user always hears a confirmation. + if (!options?.force && isNotificationSoundPlaying) { + return { played: false, reason: 'deduped' } + } + + const resolved = (await ipcRenderer.invoke( + 'notifications:resolveSoundPath' + )) as NotificationSoundPathResult + if (!resolved.ok) { + if (cachedNotificationSound) { + disposeCachedNotificationSound() + } + return { played: false, reason: resolved.reason } + } + + let entry = cachedNotificationSound + if (!entry || entry.path !== resolved.path) { + const sound = (await ipcRenderer.invoke( + 'notifications:loadSound' + )) as NotificationSoundDataResult + if (!sound.ok) { + disposeCachedNotificationSound() + return { played: false, reason: sound.reason } + } + const arrayBuffer = new ArrayBuffer(sound.data.byteLength) + new Uint8Array(arrayBuffer).set(sound.data) + const blob = new Blob([arrayBuffer], { type: sound.mimeType }) + disposeCachedNotificationSound() + const blobUrl = URL.createObjectURL(blob) + entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } + cachedNotificationSound = entry + } + + const audio = entry.audio + // Why: restart-from-zero on every play so a burst of triggers replays + // the sound from the start instead of stacking overlapping copies. + // Matches GNOME canberra and VS Code AccessibilitySignalService. + audio.currentTime = 0 + isNotificationSoundPlaying = true + const release = (): void => { + isNotificationSoundPlaying = false + } + audio.addEventListener('ended', release, { once: true }) + audio.addEventListener('error', release, { once: true }) + try { + await audio.play() + } catch { + release() + return { played: false, reason: 'playback-failed' } + } + return { played: true } + } catch { + isNotificationSoundPlaying = false + return { played: false, reason: 'playback-failed' } + } + } + }, + + onboarding: { + get: (): Promise => ipcRenderer.invoke('onboarding:get'), + update: ( + updates: Partial> & { + checklist?: Partial + } + ): Promise => ipcRenderer.invoke('onboarding:update', updates) }, developerPermissions: { @@ -717,6 +1037,12 @@ const api = { ipcRenderer.invoke('developerPermissions:openSettings', args) }, + computerUsePermissions: { + getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), + openSetup: (args?: { id?: string }): Promise => + ipcRenderer.invoke('computerUsePermissions:openSetup', args) + }, + shell: { openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), @@ -732,6 +1058,8 @@ const api = { pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), + pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), + pickDirectory: (args: { defaultPath?: string }): Promise => ipcRenderer.invoke('shell:pickDirectory', args), @@ -739,12 +1067,13 @@ const api = { ipcRenderer.invoke('shell:copyFile', args) }, - sidekick: { - import: (): Promise => ipcRenderer.invoke('sidekick:import'), - read: (id: string, fileName: string): Promise => - ipcRenderer.invoke('sidekick:read', id, fileName), - delete: (id: string, fileName: string): Promise => - ipcRenderer.invoke('sidekick:delete', id, fileName) + pet: { + import: (): Promise => ipcRenderer.invoke('pet:import'), + importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), + read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('pet:read', id, fileName, kind), + delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('pet:delete', id, fileName, kind) }, browser: { @@ -752,6 +1081,7 @@ const api = { browserPageId: string workspaceId: string worktreeId: string + sessionProfileId?: string | null webContentsId: number }): Promise => ipcRenderer.invoke('browser:registerGuest', args), @@ -932,6 +1262,17 @@ const api = { return () => ipcRenderer.removeListener('browser:activateView', listener) }, + onPaneFocus: ( + callback: (data: { worktreeId: string | null; browserPageId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string | null; browserPageId: string } + ) => callback(data) + ipcRenderer.on('browser:pane-focus', listener) + return () => ipcRenderer.removeListener('browser:pane-focus', listener) + }, + onOpenLinkInOrcaTab: ( callback: (event: { browserPageId: string; url: string }) => void ): (() => void) => { @@ -1265,6 +1606,19 @@ const api = { baseRef: string connectionId?: string }): Promise => ipcRenderer.invoke('git:branchCompare', args), + upstreamStatus: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), + fetch: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:fetch', args), + push: (args: { + worktreePath: string + publish?: boolean + connectionId?: string + }): Promise => ipcRenderer.invoke('git:push', args), + pull: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:pull', args), branchDiff: (args: { worktreePath: string compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } @@ -1272,6 +1626,11 @@ const api = { oldPath?: string connectionId?: string }): Promise => ipcRenderer.invoke('git:branchDiff', args), + commit: (args: { + worktreePath: string + message: string + connectionId?: string + }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), stage: (args: { worktreePath: string filePath: string @@ -1333,13 +1692,8 @@ const api = { ipcRenderer.on('ui:openQuickOpen', listener) return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) }, - onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, tab: 'quick' | 'create-from') => { - // Why: older main-process builds may send this event without a payload - // — default to 'quick' so the preload contract stays forward-compatible - // during a partial rollout where only one side has shipped the tab arg. - callback(tab ?? 'quick') - } + onOpenNewWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openNewWorkspace', listener) return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) }, @@ -1362,11 +1716,16 @@ const api = { return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) }, onRequestTabCreate: ( - callback: (data: { requestId: string; url: string; worktreeId?: string }) => void + callback: (data: { + requestId: string + url: string + worktreeId?: string + sessionProfileId?: string + }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { requestId: string; url: string; worktreeId?: string } + data: { requestId: string; url: string; worktreeId?: string; sessionProfileId?: string } ) => callback(data) ipcRenderer.on('browser:requestTabCreate', listener) return () => ipcRenderer.removeListener('browser:requestTabCreate', listener) @@ -1378,6 +1737,19 @@ const api = { }): void => { ipcRenderer.send('browser:tabCreateReply', reply) }, + onRequestTabSetProfile: ( + callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { requestId: string; browserPageId: string; profileId: string } + ) => callback(data) + ipcRenderer.on('browser:requestTabSetProfile', listener) + return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) + }, + replyTabSetProfile: (reply: { requestId: string; error?: string }): void => { + ipcRenderer.send('browser:tabSetProfileReply', reply) + }, onRequestTabClose: ( callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void ): (() => void) => { @@ -1426,6 +1798,11 @@ const api = { ipcRenderer.on('ui:switchTab', listener) return () => ipcRenderer.removeListener('ui:switchTab', listener) }, + onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTabAcrossAllTypes', listener) + return () => ipcRenderer.removeListener('ui:switchTabAcrossAllTypes', listener) + }, onSwitchTerminalTab: (callback: (direction: 1 | -1) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) ipcRenderer.on('ui:switchTerminalTab', listener) @@ -1446,6 +1823,7 @@ const api = { repoId: string worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } + startup?: { command: string; env?: Record } }) => void ): (() => void) => { const listener = ( @@ -1454,17 +1832,32 @@ const api = { repoId: string worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } + startup?: { command: string; env?: Record } } ) => callback(data) ipcRenderer.on('ui:activateWorktree', listener) return () => ipcRenderer.removeListener('ui:activateWorktree', listener) }, onCreateTerminal: ( - callback: (data: { worktreeId: string; command?: string; title?: string }) => void + callback: (data: { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { worktreeId: string; command?: string; title?: string } + data: { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + } ) => callback(data) ipcRenderer.on('ui:createTerminal', listener) return () => ipcRenderer.removeListener('ui:createTerminal', listener) @@ -1542,6 +1935,12 @@ const api = { ipcRenderer.on('ui:closeTerminal', listener) return () => ipcRenderer.removeListener('ui:closeTerminal', listener) }, + onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:sleepWorktree', listener) + return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) + }, onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => callback(direction) @@ -1586,12 +1985,55 @@ const api = { setMarkdownEditorFocused: (focused: boolean): void => { ipcRenderer.send('ui:setMarkdownEditorFocused', focused) }, + onRichMarkdownContextCommand: ( + callback: (payload: RichMarkdownContextMenuCommandPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: RichMarkdownContextMenuCommandPayload + ) => callback(payload) + ipcRenderer.on(richMarkdownContextMenuCommandChannel, listener) + return () => ipcRenderer.removeListener(richMarkdownContextMenuCommandChannel, listener) + }, onFullscreenChanged: (callback: (isFullScreen: boolean) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, isFullScreen: boolean) => callback(isFullScreen) ipcRenderer.on('window:fullscreen-changed', listener) return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) }, + /** Windows only: minimize the window via the renderer-drawn title bar. */ + minimize: (): void => { + ipcRenderer.send('window:minimize') + }, + /** Windows only: toggle maximize/restore via the renderer-drawn title bar. */ + maximize: (): void => { + ipcRenderer.send('window:maximize') + }, + /** Windows only: read the current maximize state on mount, since + * window:maximize-changed only fires on transitions and a window that + * starts maximized would otherwise show the wrong icon. */ + isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), + /** Windows only: subscribe to maximize state changes so the renderer-drawn + * maximize button can show the correct restore/maximize icon. */ + onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => + callback(isMaximized) + ipcRenderer.on('window:maximize-changed', listener) + return () => ipcRenderer.removeListener('window:maximize-changed', listener) + }, + /** Windows only: request a close from the renderer-drawn close button. + * Routes through main so the BrowserWindow 'close' event fires and the + * terminal-running confirmation guard in the renderer stays active. + * window.close() is unreliable in sandboxed renderers. */ + requestClose: (): void => { + ipcRenderer.send('window:request-close') + }, + /** Windows only: pop up the application menu at the cursor position. + * Replicates the Alt-key reveal that autoHideMenuBar normally provides, + * triggered by the ··· button in the renderer-drawn title bar. */ + popupMenu: (): void => { + ipcRenderer.send('menu:popup') + }, /** Fired by the main process when the user tries to close the window * (X button, Cmd+Q, etc.). Renderer should show a confirmation dialog * if terminals are still running, then call confirmWindowClose(). @@ -1657,7 +2099,43 @@ const api = { runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), - getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus') + getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + getTerminalFitOverrides: (): Promise< + { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), + restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => + ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { ptyId: string; mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number } + ) => callback(data) + ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) + }, + onTerminalDriverChanged: ( + callback: (event: { + ptyId: string + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + ptyId: string + driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } + } + ) => callback(data) + ipcRenderer.on('runtime:terminalDriverChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) + } }, rateLimits: { @@ -1701,6 +2179,9 @@ const api = { getState: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:getState', args), + needsPassphrasePrompt: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), + testConnection: (args: { targetId: string }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => @@ -1808,39 +2289,55 @@ const api = { getConfig: () => preloadE2EConfig }, + mobile: { + listNetworkInterfaces: (): Promise<{ + interfaces: { name: string; address: string }[] + }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), + + getPairingQR: (args?: { + address?: string + rotate?: boolean + }): Promise< + | { available: false } + | { + available: true + qrDataUrl: string + pairingUrl: string + endpoint: string + deviceId: string + } + > => ipcRenderer.invoke('mobile:getPairingQR', args), + + listDevices: (): Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> => ipcRenderer.invoke('mobile:listDevices'), + + revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeDevice', args), + + isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => + ipcRenderer.invoke('mobile:isWebSocketReady') + }, + agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ - onSet: ( - callback: (data: { - paneKey: string - tabId?: string - worktreeId?: string - state: AgentStatusState - prompt?: string - agentType?: string - toolName?: string - toolInput?: string - lastAssistantMessage?: string - interrupted?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - paneKey: string - tabId?: string - worktreeId?: string - state: AgentStatusState - prompt?: string - agentType?: string - toolName?: string - toolInput?: string - lastAssistantMessage?: string - interrupted?: boolean - } - ) => callback(data) + onSet: (callback: (data: AgentStatusIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusIpcPayload) => + callback(data) ipcRenderer.on('agentStatus:set', listener) return () => ipcRenderer.removeListener('agentStatus:set', listener) + }, + /** Pull the current cached hook statuses after renderer workspace-session + * hydration. This avoids losing startup replays before the renderer + * knows which tabs exist. */ + getSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getSnapshot'), + /** Drop the cached hook status for a paneKey on both sides — main-process + * cache (lastStatusByPaneKey) and on-disk last-status file. Fired from + * the renderer when the user dismisses a retained row so a relaunch + * cannot resurrect it. Fire-and-forget; no response. */ + drop: (paneKey: string): void => { + ipcRenderer.send('agentStatus:drop', paneKey) } } } diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts new file mode 100644 index 00000000000..700cb6426fa --- /dev/null +++ b/src/relay/agent-hook-server.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' + +describe('RelayAgentHookServer', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relay-hook-server-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'remote', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } + }) + }) + expect(res.status).toBe(204) + expect(forward).toHaveBeenCalledTimes(1) + const envelope = forward.mock.calls[0][0] + expect(envelope.source).toBe('claude') + expect(envelope.paneKey).toBe('tab-1:0') + expect(envelope.tabId).toBe('tab-1') + expect(envelope.connectionId).toBeNull() + expect(envelope.payload.state).toBe('working') + expect(envelope.payload.prompt).toBe('hi') + // Why: the relay forwards body env/version verbatim so Orca's existing + // warn-once cross-build / dev-vs-prod diagnostics still fire on remote. + expect(envelope.env).toBe('remote') + expect(envelope.version).toBe('1') + } finally { + server.stop() + } + }) + + it('rejects requests with the wrong bearer token (403)', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': 'wrong' + }, + body: '{}' + }) + expect(res.status).toBe(403) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('replays cached payloads on demand', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-1:0', + tabId: 'tab-1', + env: 'remote', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cache me' } + }) + }) + forward.mockClear() + const replayed = server.replayCachedPayloadsForPanes() + expect(replayed).toBe(1) + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0].payload.prompt).toBe('cache me') + // Why: replay must preserve the wire envelope's env/version (and source) + // so Orca's warn-once cross-build / dev-vs-prod diagnostics fire on + // replayed events the same as on live POST events. + expect(forward.mock.calls[0][0].source).toBe('claude') + expect(forward.mock.calls[0][0].env).toBe('remote') + expect(forward.mock.calls[0][0].version).toBe('1') + } finally { + server.stop() + } + }) + + it('does not replay paneKeys after clearPaneState', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-1:0', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'gone' } + }) + }) + server.clearPaneState('tab-1:0') + forward.mockClear() + const replayed = server.replayCachedPayloadsForPanes() + expect(replayed).toBe(0) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('exposes ORCA_AGENT_HOOK_* env vars after start', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_PORT).toMatch(/^\d+$/) + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote') + expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1') + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy() + } finally { + server.stop() + } + }) +}) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts new file mode 100644 index 00000000000..ca7e3ead63c --- /dev/null +++ b/src/relay/agent-hook-server.ts @@ -0,0 +1,265 @@ +// Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts +// a loopback HTTP server (same shape as Orca's main-process server: bind +// 127.0.0.1:0, bearer-token auth, /hook/ routing) and forwards every +// parsed payload via a callback so `relay.ts` can re-emit it as an +// `agent.hook` JSON-RPC notification across the existing SSH channel. +// +// Per-instance state (warn-once Sets, last-status cache, last-prompt / +// last-tool caches) lives on `HookListenerState`. The cache is bounded to one +// entry per paneKey — see docs/design/agent-status-over-ssh.md §5 (Path 3, +// request-driven replay) for the rationale. +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { randomUUID } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' + +import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + getEndpointFileName, + HOOK_REQUEST_SLOWLORIS_MS, + normalizeHookPayload, + readRequestBody, + resolveHookSource, + writeEndpointFile, + type AgentHookEventPayload, + type HookListenerState +} from '../shared/agent-hook-listener' +import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' + +export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void + +// Why: relay's userData equivalent. Lives under $HOME so each user on a +// shared dev box gets their own dir, owned 0o700. Mirrors RELAY_REMOTE_DIR +// from `ssh-relay-deploy.ts` but stays local to this module — the hook +// server is the only consumer. +const RELAY_HOOKS_DIR_NAME = '.orca-relay' +const RELAY_HOOKS_SUBDIR = 'agent-hooks' + +function defaultEndpointDir(): string { + return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR) +} + +export type RelayHookServerOptions = { + /** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */ + endpointDir?: string + /** Env tag forwarded into hook payloads (warn-once cross-build diagnostic). + * Defaults to "remote" — distinct from Orca's local 'production'/'development'. */ + env?: string + /** Called once per parsed payload. The relay wires this to + * `dispatcher.notify('agent.hook', envelope)`. */ + forward: RelayHookForward +} + +export class RelayAgentHookServer { + private server: ReturnType | null = null + private port = 0 + private token = '' + private env: string + private endpointDir: string + private endpointFilePath: string + private endpointFileWritten = false + private state: HookListenerState = createHookListenerState() + // Why: the shared `HookListenerState.lastStatusByPaneKey` cache only stores + // `AgentHookEventPayload` (no wire-envelope fields). Replay must still emit + // the original `source`/`env`/`version` so Orca's warn-once diagnostics fire + // identically to the live POST path. Keep this as a per-instance sidecar map + // so the shared listener type stays unchanged. Invariant: every key present + // in `state.lastStatusByPaneKey` must also be present here — populated and + // cleared in lockstep on the live POST path, clearPaneState, and stop(). + private lastEnvelopeMetaByPaneKey: Map< + string, + { source: AgentHookSource; env?: string; version?: string } + > = new Map() + private forward: RelayHookForward + + constructor(options: RelayHookServerOptions) { + this.env = options.env ?? 'remote' + this.endpointDir = options.endpointDir ?? defaultEndpointDir() + this.endpointFilePath = join(this.endpointDir, getEndpointFileName()) + this.forward = options.forward + } + + async start(): Promise { + if (this.server) { + return + } + this.token = randomUUID() + this.endpointFileWritten = false + this.server = createServer((req, res) => this.handleRequest(req, res)) + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + this.server?.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION + }) + resolve() + } + this.server!.once('error', onStartupError) + // Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only — + // the agent CLI inside the same remote box reaches us via curl + // 127.0.0.1:PORT; nobody outside the box can. + this.server!.listen(0, '127.0.0.1', onListening) + }) + } + + stop(): void { + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.endpointFileWritten = false + clearAllListenerCaches(this.state) + this.lastEnvelopeMetaByPaneKey.clear() + } + + /** Request-driven replay: walks the per-paneKey last-payload cache and + * forwards each entry as a fresh notification. Called after Orca has + * re-wired its `agent.hook` handler on the new mux post-`--connect`. + * The relay-driver issues the replay forwards BEFORE returning from the + * request handler so the response strictly trails all replayed + * notifications on the dispatcher's single write callback. */ + replayCachedPayloadsForPanes(): number { + let count = 0 + for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) { + const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) + // Why: invariant — every paneKey in the shared status cache is populated + // in lockstep with `lastEnvelopeMetaByPaneKey`. If meta is missing, + // something has drifted; skip rather than fall back to a guessed source + // that would mis-tag the event downstream. + if (!meta) { + continue + } + this.forwardEvent(event, meta.source, meta.env, meta.version) + count++ + } + return count + } + + /** Drop a paneKey's cached entries on PTY exit so a terminated pane never + * resurfaces as a ghost event on a later reconnect. Symmetric with the + * local server's clearPaneState on PTY teardown. */ + clearPaneState(paneKey: string): void { + clearPaneCacheState(this.state, paneKey) + this.lastEnvelopeMetaByPaneKey.delete(paneKey) + } + + /** Env vars to inject into every relay-spawned PTY so the hook script / + * in-process plugin POSTs to this loopback server. */ + buildPtyEnv(): Record { + if (this.port <= 0 || !this.token) { + return {} + } + const env: Record = { + ORCA_AGENT_HOOK_PORT: String(this.port), + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + } + if (this.endpointFileWritten) { + env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePath + } + return env + } + + /** Test-only / diagnostics accessor. */ + getCoordinates(): { port: number; token: string; endpointFilePath: string } { + return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath } + } + + // ─── Private ────────────────────────────────────────────────────── + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + if (req.headers['x-orca-agent-hook-token'] !== this.token) { + res.writeHead(403) + res.end() + return + } + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + req.destroy() + }) + try { + const body = await readRequestBody(req) + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + const event = normalizeHookPayload(this.state, source, body, this.env) + if (event) { + this.state.lastStatusByPaneKey.set(event.paneKey, event) + // TODO: once normalizeHookPayload returns validated env/version, drop + // bodyEnv/bodyVersion and source those from the listener result instead. + const env = this.bodyEnv(body) + const version = this.bodyVersion(body) + this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) + this.forwardEvent(event, source, env, version) + } + res.writeHead(204) + res.end() + } catch { + // Why: agent hooks must fail open — return success on parse / size / + // timeout errors so a buggy agent script never blocks the agent run. + res.writeHead(204) + res.end() + } + } + + private forwardEvent( + event: AgentHookEventPayload, + source: AgentHookSource, + env?: string, + version?: string + ): void { + const envelope: AgentHookRelayEnvelope = { + source, + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + connectionId: null, + env, + version, + payload: event.payload + } + this.forward(envelope) + } + + private bodyEnv(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) { + return undefined + } + const v = (body as Record).env + return typeof v === 'string' && v.length > 0 ? v : undefined + } + + private bodyVersion(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) { + return undefined + } + const v = (body as Record).version + return typeof v === 'string' && v.length > 0 ? v : undefined + } +} diff --git a/src/relay/context.ts b/src/relay/context.ts index e6396890206..856ad382a27 100644 --- a/src/relay/context.ts +++ b/src/relay/context.ts @@ -1,7 +1,5 @@ -import { resolve, relative, isAbsolute } from 'path' +import { resolve } from 'path' import { homedir } from 'os' -import { realpathSync } from 'fs' -import { realpath } from 'fs/promises' // Why: Node's fs APIs don't understand shell tilde expansion. Old repos may // have been stored with `~` or `~/…` paths before the client-side fix, so the @@ -16,69 +14,18 @@ export function expandTilde(p: string): string { return p } -// Why: mutating FS operations on the remote must be scoped to workspace roots -// registered by the main process. Without this, a compromised or buggy client -// could delete arbitrary files on the remote host. +// Why: the relay runs as the SSH user and trusts the renderer process. A +// compromised renderer can already weaponize pty.spawn and git.exec to reach +// any path the SSH user can reach, so the FS-side allowlist provided friction +// without meaningfully narrowing the blast radius. See +// docs/relay-fs-allowlist-removal.md. +// +// registerRoot is retained as a no-op so existing session.registerRoot RPC +// calls (notification + request) remain valid during the relay-deploy upgrade +// window where an old main may still call into a new relay (and vice versa). +// Tracked for deletion once the relay-version floor moves past the cutover. export class RelayContext { - readonly authorizedRoots = new Set() - - // Why: before any root is registered there is a race window where - // authorizedRoots is empty. If we allowed all paths during that window a - // compromised client could read or mutate arbitrary files before the first - // workspace root is registered. We track registration explicitly and reject - // every validatePath call until at least one root has been added. - private rootsRegistered = false - - registerRoot(rootPath: string): void { - const resolved = resolve(expandTilde(rootPath)) - this.authorizedRoots.add(resolved) - // Why: on macOS, /tmp is a symlink to /private/tmp. If a root is registered - // as /tmp/workspace, validatePathResolved would resolve it to /private/tmp/ - // workspace, which fails the textual root check. Register both forms so the - // resolved path also passes validation. - try { - const real = realpathSync(resolved) - if (real !== resolved) { - this.authorizedRoots.add(real) - } - } catch { - // Root doesn't exist yet — textual form is sufficient - } - this.rootsRegistered = true - } - - validatePath(targetPath: string): void { - if (!this.rootsRegistered) { - throw new Error('No workspace roots registered yet — path validation denied') - } - - const resolved = resolve(expandTilde(targetPath)) - for (const root of this.authorizedRoots) { - const rel = relative(root, resolved) - if (!rel.startsWith('..') && !isAbsolute(rel)) { - return - } - } - throw new Error(`Path outside authorized workspace: ${targetPath}`) - } - - // Why: validatePath only normalizes `..` textually. A symlink inside the - // workspace pointing outside it (e.g., workspace/evil -> /etc/) would pass - // textual validation. This async variant resolves symlinks via realpath - // before checking the path, closing the symlink traversal vector. - async validatePathResolved(targetPath: string): Promise { - this.validatePath(targetPath) - try { - const real = await realpath(targetPath) - this.validatePath(real) - } catch (err) { - // Why: ENOENT/ENOTDIR means the path doesn't exist yet (e.g., createFile) - // so the textual check above is sufficient. Other errors (EACCES, EIO) - // indicate real problems that should propagate. - const code = (err as NodeJS.ErrnoException).code - if (code !== 'ENOENT' && code !== 'ENOTDIR') { - throw err - } - } + registerRoot(_rootPath: string): void { + // intentionally empty } } diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index d114f5837a0..d8f2f9077ef 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,5 +1,8 @@ -import { readFile, stat } from 'fs/promises' +import { open, readFile, stat } from 'fs/promises' import { extname } from 'path' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' +import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry' import { BINARY_PROBE_BYTES, IMAGE_MIME_TYPES, @@ -24,8 +27,6 @@ export async function readRelayFileContent(filePath: string) { return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } } - // Why: SSH reads serialize through bounded relay frames; probing large - // unknown files prevents binary archives from consuming frame budget. if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { return { content: '', isBinary: true } } @@ -36,3 +37,171 @@ export async function readRelayFileContent(filePath: string) { } return { content: buffer.toString('utf-8'), isBinary: false } } + +export type StreamMetadata = { + streamId?: number + totalSize: number + isBinary: boolean + isImage?: boolean + mimeType?: string + /** On-the-wire encoding of each chunk's `data` field. Always 'base64'. */ + chunkEncoding?: 'base64' + /** Encoding of the assembled FileReadResult.content. */ + resultEncoding?: 'base64' | 'utf-8' + /** True for empty files and binary archives that short-circuit without pumping. */ + empty?: boolean +} + +export async function readRelayFileStreamMetadata( + filePath: string, + dispatcher: RelayDispatcher, + registry: RelayStreamRegistry, + context: RequestContext +): Promise { + const stats = await stat(filePath) + const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (stats.size > sizeLimit) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` + ) + } + + if (stats.size === 0) { + return { + totalSize: 0, + isBinary: !!mimeType, + mimeType, + isImage: mimeType ? true : undefined, + empty: true + } + } + // Why: unlike the legacy single-shot path, streaming does not read the full + // buffer before classifying content. Probe every unknown file so small binary + // files do not get decoded as UTF-8 text over SSH. + if (!mimeType && (await isBinaryFilePrefix(filePath))) { + return { totalSize: 0, isBinary: true, empty: true } + } + + const handle = await open(filePath, 'r') + let streamId: number + try { + streamId = registry.register(handle) + } catch (err) { + await handle.close() + throw err + } + + process.stderr.write(`[relay] stream start id=${streamId} size=${stats.size}\n`) + + // Why: pumpChunks owns its own try/finally for handle release; the outer + // setImmediate kicks the pump off the metadata-response task so the client + // sees the response before the first chunk frame. + setImmediate(() => { + void pumpChunks(streamId, stats.size, dispatcher, registry, context) + }) + + return { + streamId, + totalSize: stats.size, + isBinary: !!mimeType, + isImage: mimeType ? true : undefined, + mimeType, + chunkEncoding: 'base64', + resultEncoding: mimeType ? 'base64' : 'utf-8' + } +} + +async function pumpChunks( + streamId: number, + totalSize: number, + dispatcher: RelayDispatcher, + registry: RelayStreamRegistry, + context: RequestContext +): Promise { + const entry = registry.get(streamId) + if (!entry) { + return + } + const buffer = Buffer.allocUnsafe(STREAM_CHUNK_SIZE) + let offset = 0 + let seq = 0 + let endReason: 'end' | 'aborted' | 'stale' | 'error' = 'end' + let errorCode: string | null = null + let errorMessage: string | null = null + + try { + try { + while (offset < totalSize) { + if (context.isStale()) { + endReason = 'stale' + break + } + if (registry.isAborted(streamId)) { + endReason = 'aborted' + break + } + const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) + const { bytesRead } = await entry.handle.read(buffer, 0, want, offset) + if (bytesRead === 0) { + endReason = 'error' + errorCode = 'ESTREAMTRUNCATED' + errorMessage = `File truncated mid-stream: expected ${totalSize}, got ${offset}` + break + } + if (context.isStale()) { + endReason = 'stale' + break + } + if (registry.isAborted(streamId)) { + endReason = 'aborted' + break + } + const data = buffer.subarray(0, bytesRead).toString('base64') + dispatcher.notify('fs.streamChunk', { streamId, seq, data }) + offset += bytesRead + seq += 1 + } + } catch (err) { + // Why: a read() rejection that races with disposeAll surfaces as EBADF; + // treat as aborted so we don't emit a spurious streamError to a client + // that is already gone. + const code = (err as { code?: string }).code + if (code === 'EBADF' && registry.isAborted(streamId)) { + endReason = 'aborted' + } else { + endReason = 'error' + errorCode = code ?? 'ESTREAMREAD' + errorMessage = err instanceof Error ? err.message : String(err) + } + } + + try { + if (endReason === 'end') { + dispatcher.notify('fs.streamEnd', { streamId }) + process.stderr.write(`[relay] stream end id=${streamId}\n`) + } else if (endReason === 'error') { + dispatcher.notify('fs.streamError', { + streamId, + code: errorCode ?? 'ESTREAMERROR', + message: errorMessage ?? 'stream error' + }) + process.stderr.write(`[relay] stream error id=${streamId} code=${errorCode}\n`) + } else if (endReason === 'aborted') { + process.stderr.write(`[relay] stream cancel id=${streamId}\n`) + } else { + process.stderr.write(`[relay] stream stale id=${streamId}\n`) + } + } catch (err) { + process.stderr.write( + `[relay] stream notify failed id=${streamId}: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } finally { + await registry.release(streamId) + } +} + +export function isTooManyStreamsError(err: unknown): err is TooManyStreamsError { + return err instanceof Error && (err as { code?: number }).code === RelayErrorCode.TooManyStreams +} diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts new file mode 100644 index 00000000000..25db034175e --- /dev/null +++ b/src/relay/fs-handler-stream.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { FsHandler } from './fs-handler' +import { RelayContext } from './context' +import type { RelayDispatcher } from './dispatcher' +import * as fs from 'fs/promises' +import * as path from 'path' +import { mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' + +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) + +type Notification = { method: string; params?: Record } + +function createMockDispatcher() { + const requestHandlers = new Map< + string, + (params: Record, context?: { isStale: () => boolean }) => Promise + >() + const notificationHandlers = new Map) => void>() + const notifications: Notification[] = [] + return { + onRequest: vi.fn( + ( + method: string, + handler: typeof requestHandlers extends Map ? H : never + ) => { + requestHandlers.set(method, handler as never) + } + ), + onNotification: vi.fn((method: string, handler: (params: Record) => void) => { + notificationHandlers.set(method, handler) + }), + notify: vi.fn((method: string, params?: Record) => { + notifications.push({ method, params }) + }), + _notifications: notifications, + callRequest( + method: string, + params: Record = {}, + context?: { isStale: () => boolean } + ) { + const handler = requestHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + return handler(params, context) + }, + callNotification(method: string, params: Record = {}) { + const handler = notificationHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + handler(params) + } + } +} + +type StreamOutcome = { + chunks: { seq: number; data: string }[] + end: { streamId: number } | null + err: { code: string; message: string } | null +} + +function collectStream(d: ReturnType): StreamOutcome { + const chunks: { seq: number; data: string }[] = [] + let end: { streamId: number } | null = null + let err: { code: string; message: string } | null = null + for (const n of d._notifications) { + if (n.method === 'fs.streamChunk') { + chunks.push({ seq: n.params!.seq as number, data: n.params!.data as string }) + } else if (n.method === 'fs.streamEnd') { + end = { streamId: n.params!.streamId as number } + } else if (n.method === 'fs.streamError') { + err = { + code: n.params!.code as string, + message: n.params!.message as string + } + } + } + return { chunks, end, err } +} + +async function flush(times = 5): Promise { + for (let i = 0; i < times; i++) { + await new Promise((r) => setImmediate(r)) + } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor: predicate did not become true in time') + } + await new Promise((r) => setImmediate(r)) + } +} + +describe('FsHandler readFileStream', () => { + let dispatcher: ReturnType + let handler: FsHandler + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-')) + dispatcher = createMockDispatcher() + handler = new FsHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + }) + + afterEach(async () => { + handler.dispose() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('streams a binary file in chunked notifications', async () => { + const filePath = path.join(tmpDir, 'image.png') + const content = Buffer.alloc(300 * 1024, 0x42) + writeFileSync(filePath, content) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { isStale: () => false } + )) as { streamId: number; totalSize: number; resultEncoding: string } + expect(meta.streamId).toBeDefined() + expect(meta.totalSize).toBe(content.length) + expect(meta.resultEncoding).toBe('base64') + + await waitFor(() => collectStream(dispatcher).end !== null) + const { chunks, end, err } = collectStream(dispatcher) + expect(err).toBeNull() + expect(end).toEqual({ streamId: meta.streamId }) + const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) + expect(reassembled.equals(content)).toBe(true) + }) + + it('returns empty:true for 0-byte files without opening a handle', async () => { + const filePath = path.join(tmpDir, 'empty.txt') + writeFileSync(filePath, '') + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { isStale: () => false } + )) as { totalSize: number; empty: boolean; streamId?: number } + expect(meta.empty).toBe(true) + expect(meta.totalSize).toBe(0) + expect(meta.streamId).toBeUndefined() + + await flush() + const { chunks } = collectStream(dispatcher) + expect(chunks).toHaveLength(0) + }) + + it('returns empty:true for binary archives over the probe threshold', async () => { + const filePath = path.join(tmpDir, 'archive.bin') + const content = Buffer.alloc(20 * 1024, 0x61) + content[0] = 0x00 + writeFileSync(filePath, content) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { isStale: () => false } + )) as { totalSize: number; empty: boolean; isBinary: boolean } + expect(meta.empty).toBe(true) + expect(meta.isBinary).toBe(true) + }) + + it('returns empty:true for small binary files under the probe threshold', async () => { + const filePath = path.join(tmpDir, 'small.bin') + const content = Buffer.from([0x41, 0x00, 0x42]) + writeFileSync(filePath, content) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { isStale: () => false } + )) as { totalSize: number; empty: boolean; isBinary: boolean; streamId?: number } + expect(meta.empty).toBe(true) + expect(meta.isBinary).toBe(true) + expect(meta.totalSize).toBe(0) + expect(meta.streamId).toBeUndefined() + }) + + it('rejects when totalSize exceeds the binary cap', async () => { + const filePath = path.join(tmpDir, 'huge.png') + writeFileSync(filePath, Buffer.alloc(51 * 1024 * 1024)) + + await expect( + dispatcher.callRequest('fs.readFileStream', { filePath }, { isStale: () => false }) + ).rejects.toThrow(/File too large/) + }) + + it('exits the pump and emits no further chunks when isStale flips', async () => { + const filePath = path.join(tmpDir, 'big.png') + const content = Buffer.alloc(800 * 1024, 0x42) + writeFileSync(filePath, content) + + let stale = false + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { isStale: () => stale } + )) as { streamId: number } + + await new Promise((r) => setImmediate(r)) + stale = true + await flush(10) + + const { end, err } = collectStream(dispatcher) + expect(end).toBeNull() + expect(err).toBeNull() + expect(meta.streamId).toBeGreaterThan(0) + }) + + it('honors fs.cancelStream by stopping the pump and emitting no end frame', async () => { + const filePath = path.join(tmpDir, 'cancel.png') + writeFileSync(filePath, Buffer.alloc(2 * 1024 * 1024, 0x42)) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { isStale: () => false } + )) as { streamId: number } + + await new Promise((r) => setImmediate(r)) + dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }) + await flush(10) + + const { end, err } = collectStream(dispatcher) + expect(end).toBeNull() + expect(err).toBeNull() + }) + + it('rejects the 17th concurrent stream with TooManyStreams', async () => { + const paths: string[] = [] + for (let i = 0; i < 17; i++) { + const p = path.join(tmpDir, `s${i}.png`) + writeFileSync(p, Buffer.alloc(8 * 1024 * 1024, 0x42)) + paths.push(p) + } + + const isStale = () => false + for (let i = 0; i < 16; i++) { + await dispatcher.callRequest('fs.readFileStream', { filePath: paths[i] }, { isStale }) + } + await expect( + dispatcher.callRequest('fs.readFileStream', { filePath: paths[16] }, { isStale }) + ).rejects.toThrow(/Too many concurrent streams/) + await flush(50) + }, 20_000) +}) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index d49d082b0ea..0ea0fa1799f 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -22,8 +22,10 @@ import type { SearchResult as SharedSearchResult } from '../shared/types' // the old 5MB search cap would block common JSON/log files before Monaco's // large-file optimizations can handle them. export const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 -// 10MB for relayed binaries (base64 → ~13.3MB frame payload at 16MB relay cap) -export const MAX_PREVIEWABLE_BINARY_SIZE = 10 * 1024 * 1024 +// Why: matches the local cap (src/main/ipc/filesystem.ts MAX_PREVIEWABLE_BINARY_SIZE). +// Reads above the legacy 16MB single-frame budget go through fs.readFileStream, +// which chunks at STREAM_CHUNK_SIZE; see docs/relay-file-stream-design.md. +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 export const BINARY_PROBE_BYTES = 8192 export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS export const DEFAULT_MAX_RESULTS = 2000 diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index aef0fea7ff8..ed9120d54d6 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -76,7 +76,6 @@ describe('FsHandler', () => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-')) dispatcher = createMockDispatcher() const ctx = new RelayContext() - ctx.registerRoot(tmpDir) handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx) }) @@ -120,6 +119,25 @@ describe('FsHandler', () => { expect(result.find((e) => e.name === 'aaa.txt')).toBeDefined() }) + it('readDir reports symlinked directories as directories', async () => { + const targetDir = path.join(tmpDir, 'external-models') + const linkPath = path.join(tmpDir, 'Model') + mkdirSync(targetDir) + symlinkSync(targetDir, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + + const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { + name: string + isDirectory: boolean + isSymlink: boolean + }[] + + expect(result.find((e) => e.name === 'Model')).toEqual({ + name: 'Model', + isDirectory: true, + isSymlink: true + }) + }) + it('readFile returns text content for text files', async () => { const filePath = path.join(tmpDir, 'test.txt') writeFileSync(filePath, 'hello world') @@ -213,6 +231,19 @@ describe('FsHandler', () => { expect(result.type).toBe('directory') }) + it('stat returns directory type for symlinked directories', async () => { + const targetDir = path.join(tmpDir, 'external-models') + const linkPath = path.join(tmpDir, 'Model') + mkdirSync(targetDir) + symlinkSync(targetDir, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + + const result = (await dispatcher.callRequest('fs.stat', { filePath: linkPath })) as { + type: string + } + + expect(result.type).toBe('directory') + }) + it('deletePath removes files', async () => { const filePath = path.join(tmpDir, 'to-delete.txt') writeFileSync(filePath, 'bye') diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 413fb53f7fc..6f29861826c 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,7 +1,10 @@ import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'fs/promises' import { execFile } from 'child_process' +import { join } from 'path' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' +// Why: RelayContext is accepted in the constructor for protocol back-compat +// (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. import { expandTilde } from './context' import { DEFAULT_MAX_RESULTS, @@ -13,7 +16,8 @@ import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { buildInstallRgMessage } from './fs-handler-install-rg' -import { readRelayFileContent } from './fs-handler-file-read' +import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' +import { RelayStreamRegistry } from './fs-stream-registry' type WatchState = { rootPath: string @@ -22,20 +26,39 @@ type WatchState = { isStale: () => boolean } +async function isDirectoryEntry( + dirPath: string, + entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean } +): Promise { + if (entry.isDirectory()) { + return true + } + if (!entry.isSymbolicLink()) { + return false + } + try { + // Why: the file explorer needs target type for symlinked directories so a + // workspace link to an external folder expands instead of opening as a file. + return (await stat(join(dirPath, entry.name))).isDirectory() + } catch { + return false + } +} + export class FsHandler { private dispatcher: RelayDispatcher - private context: RelayContext private watches = new Map() + private streamRegistry = new RelayStreamRegistry() - constructor(dispatcher: RelayDispatcher, context: RelayContext) { + constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher - this.context = context this.registerHandlers() } private registerHandlers(): void { this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) + this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) this.dispatcher.onRequest('fs.writeFile', (p) => this.writeFile(p)) this.dispatcher.onRequest('fs.stat', (p) => this.stat(p)) this.dispatcher.onRequest('fs.deletePath', (p) => this.deletePath(p)) @@ -48,35 +71,50 @@ export class FsHandler { this.dispatcher.onRequest('fs.listFiles', (p) => this.listFiles(p)) this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context)) this.dispatcher.onNotification('fs.unwatch', (p) => this.unwatch(p)) + this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) } private async readDir(params: Record) { const dirPath = expandTilde(params.dirPath as string) - await this.context.validatePathResolved(dirPath) const entries = await readdir(dirPath, { withFileTypes: true }) - return entries - .map((entry) => ({ + const mapped = await Promise.all( + entries.map(async (entry) => ({ name: entry.name, - isDirectory: entry.isDirectory(), + isDirectory: await isDirectoryEntry(dirPath, entry), isSymlink: entry.isSymbolicLink() })) - .sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + ) + return mapped.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) } private async readFile(params: Record) { const filePath = expandTilde(params.filePath as string) - await this.context.validatePathResolved(filePath) return readRelayFileContent(filePath) } + private async readFileStream( + params: Record, + context?: { isStale: () => boolean } + ) { + const filePath = expandTilde(params.filePath as string) + const ctx = context ?? { isStale: () => false } + return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx) + } + + private cancelStream(params: Record): void { + const streamId = params.streamId as number | undefined + if (typeof streamId === 'number') { + this.streamRegistry.abort(streamId) + } + } + private async writeFile(params: Record) { const filePath = expandTilde(params.filePath as string) - await this.context.validatePathResolved(filePath) const content = params.content as string try { const fileStats = await lstat(filePath) @@ -93,23 +131,30 @@ export class FsHandler { private async stat(params: Record) { const filePath = expandTilde(params.filePath as string) - await this.context.validatePathResolved(filePath) - // Why: lstat is used instead of stat so that symlinks are reported as - // symlinks rather than being silently followed. stat() follows symlinks, - // meaning isSymbolicLink() would always return false. const stats = await lstat(filePath) + if (stats.isSymbolicLink()) { + try { + // Why: callers use stat to decide whether to read a path or enumerate + // it; symlink-to-directory must behave like its target for that choice. + const targetStats = await stat(filePath) + return { + size: targetStats.size, + type: targetStats.isDirectory() ? 'directory' : 'file', + mtime: targetStats.mtimeMs + } + } catch { + return { size: stats.size, type: 'symlink', mtime: stats.mtimeMs } + } + } let type: 'file' | 'directory' | 'symlink' = 'file' if (stats.isDirectory()) { type = 'directory' - } else if (stats.isSymbolicLink()) { - type = 'symlink' } return { size: stats.size, type, mtime: stats.mtimeMs } } private async deletePath(params: Record) { const targetPath = expandTilde(params.targetPath as string) - await this.context.validatePathResolved(targetPath) const recursive = params.recursive as boolean | undefined const stats = await stat(targetPath) if (stats.isDirectory() && !recursive) { @@ -120,9 +165,6 @@ export class FsHandler { private async createFile(params: Record) { const filePath = expandTilde(params.filePath as string) - // Why: symlinks in parent directories can redirect creation outside the - // workspace. validatePathResolved follows symlinks before checking roots. - await this.context.validatePathResolved(filePath) const { dirname } = await import('path') await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) @@ -130,45 +172,29 @@ export class FsHandler { private async createDir(params: Record) { const dirPath = expandTilde(params.dirPath as string) - await this.context.validatePathResolved(dirPath) await mkdir(dirPath, { recursive: true }) } private async rename(params: Record) { const oldPath = expandTilde(params.oldPath as string) const newPath = expandTilde(params.newPath as string) - await this.context.validatePathResolved(oldPath) - await this.context.validatePathResolved(newPath) await rename(oldPath, newPath) } private async copy(params: Record) { const source = expandTilde(params.source as string) const destination = expandTilde(params.destination as string) - // Why: cp follows symlinks — a symlink inside the workspace pointing to - // /etc would copy sensitive files into the workspace where readFile can - // exfiltrate them. - await this.context.validatePathResolved(source) - await this.context.validatePathResolved(destination) await cp(source, destination, { recursive: true }) } private async realpath(params: Record) { const filePath = expandTilde(params.filePath as string) - this.context.validatePath(filePath) - const resolved = await realpath(filePath) - // Why: a symlink inside the workspace may resolve to a path outside it. - // Returning the resolved path without validation leaks the external target. - this.context.validatePath(resolved) - return resolved + return await realpath(filePath) } private async search(params: Record) { const query = params.query as string const rootPath = expandTilde(params.rootPath as string) - // Why: a symlink inside the workspace pointing to a directory outside it - // would let rg search (and return content from) files beyond the workspace. - await this.context.validatePathResolved(rootPath) const caseSensitive = params.caseSensitive as boolean | undefined const wholeWord = params.wholeWord as boolean | undefined const useRegex = params.useRegex as boolean | undefined @@ -203,7 +229,6 @@ export class FsHandler { private async listFiles(params: Record): Promise { const rootPath = expandTilde(params.rootPath as string) - await this.context.validatePathResolved(rootPath) // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so @@ -240,7 +265,6 @@ export class FsHandler { private async watch(params: Record, context?: RequestContext) { const rootPath = expandTilde(params.rootPath as string) - this.context.validatePath(rootPath) if (this.watches.size >= 20) { throw new Error('Maximum number of file watchers reached') @@ -322,5 +346,6 @@ export class FsHandler { state.unwatchFn?.() } this.watches.clear() + void this.streamRegistry.disposeAll() } } diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts new file mode 100644 index 00000000000..b1ac9fb1f6f --- /dev/null +++ b/src/relay/fs-stream-registry.ts @@ -0,0 +1,72 @@ +import type { FileHandle } from 'fs/promises' +import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol' + +type StreamEntry = { + handle: FileHandle + aborted: boolean +} + +export class TooManyStreamsError extends Error { + readonly code = RelayErrorCode.TooManyStreams + constructor() { + super(`Too many concurrent streams (max ${MAX_CONCURRENT_STREAMS})`) + } +} + +export class RelayStreamRegistry { + private streams = new Map() + private nextId = 1 + + register(handle: FileHandle): number { + if (this.streams.size >= MAX_CONCURRENT_STREAMS) { + throw new TooManyStreamsError() + } + const streamId = this.nextId++ + this.streams.set(streamId, { handle, aborted: false }) + return streamId + } + + abort(streamId: number): void { + const entry = this.streams.get(streamId) + if (entry) { + entry.aborted = true + } + } + + isAborted(streamId: number): boolean { + return this.streams.get(streamId)?.aborted ?? true + } + + get(streamId: number): StreamEntry | undefined { + return this.streams.get(streamId) + } + + async release(streamId: number): Promise { + const entry = this.streams.get(streamId) + if (!entry) { + return + } + this.streams.delete(streamId) + try { + await entry.handle.close() + } catch { + // release runs from multiple exit paths (pump, cancel, dispose); a + // second close throws EBADF — swallow it. + } + } + + size(): number { + return this.streams.size + } + + async disposeAll(): Promise { + // Why: flag every stream as aborted so any in-flight pump exits its loop + // cleanly on the next iteration boundary instead of seeing EBADF when + // release closes the handle out from under an in-flight read. + for (const id of this.streams.keys()) { + this.abort(id) + } + const ids = Array.from(this.streams.keys()) + await Promise.all(ids.map((id) => this.release(id))) + } +} diff --git a/src/relay/git-exec-validator.test.ts b/src/relay/git-exec-validator.test.ts index ce168eede86..54039ebd7cb 100644 --- a/src/relay/git-exec-validator.test.ts +++ b/src/relay/git-exec-validator.test.ts @@ -28,7 +28,16 @@ describe('validateGitExecArgs', () => { [['config', '--get-all', 'remote.origin.url']], [['config', '--list']], [['config', '-l']], - [['config', '--get-regexp', 'user']] + [['config', '--get-regexp', 'user']], + [['for-each-ref', '--format=%(refname)', 'refs/remotes']], + [ + [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)', + '--sort=-committerdate', + 'refs/heads/*foo*' + ] + ] ])('allows %j', (args) => { expectAllowed(args) }) @@ -76,7 +85,13 @@ describe('validateGitExecArgs', () => { [['log', '-o', '/tmp/leak']], [['rev-parse', '--exec-path=/evil']], [['log', '--work-tree=/other']], - [['log', '--git-dir=/other/.git']] + [['log', '--git-dir=/other/.git']], + // Pin global-deny coverage on for-each-ref so a future allowlist + // refactor that bypassed GLOBAL_DENIED_FLAGS for this subcommand fails + // loudly. The first round of for-each-ref enablement omitted these. + [['for-each-ref', '--git-dir=/other/.git', '--format=%(refname)']], + [['for-each-ref', '--output=/tmp/leak', '--format=%(refname)']], + [['for-each-ref', '--work-tree=/other']] ])('rejects %j', (args) => { expectBlocked(args, 'Dangerous git flags are not allowed') }) diff --git a/src/relay/git-exec-validator.ts b/src/relay/git-exec-validator.ts index fa22356c53a..b881fbb6a26 100644 --- a/src/relay/git-exec-validator.ts +++ b/src/relay/git-exec-validator.ts @@ -17,6 +17,7 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'symbolic-ref', 'merge-base', 'ls-files', + 'for-each-ref', 'config' ]) const CONFIG_READ_ONLY_FLAGS = new Set(['--get', '--get-all', '--list', '--get-regexp', '-l']) diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 8d89a85d356..a24ff274523 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -217,8 +217,9 @@ export async function branchDiffEntries( return [] } + // Why: see core.quotePath rationale in getStatusOp — keep UTF-8 paths intact. const { stdout } = await git( - ['diff', '--name-status', '-M', '-C', mergeBase, headOid], + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], worktreePath ) const allChanges = parseBranchDiff(stdout) diff --git a/src/relay/git-handler-staging.test.ts b/src/relay/git-handler-staging.test.ts new file mode 100644 index 00000000000..07556f7918b --- /dev/null +++ b/src/relay/git-handler-staging.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for GitHandler commit and bulk-staging operations. + * + * Why: split from git-handler.test.ts to stay under the oxlint max-lines (300) limit. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import * as path from 'path' +import * as fs from 'fs/promises' +import { mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { execFileSync } from 'child_process' +import { GitHandler } from './git-handler' +import { RelayContext } from './context' +import { + createMockDispatcher, + gitInit, + gitCommit, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +describe('GitHandler — commit & staging', () => { + let dispatcher: MockDispatcher + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-staging-')) + dispatcher = createMockDispatcher() + const ctx = new RelayContext() + // eslint-disable-next-line no-new + new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + describe('commit', () => { + it('commits staged changes and returns success', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'content') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) + + const result = (await dispatcher.callRequest('git.commit', { + worktreePath: tmpDir, + message: 'feat: relay commit' + })) as { success: boolean; error?: string } + + expect(result).toEqual({ success: true }) + const latestMessage = execFileSync('git', ['log', '-1', '--format=%s'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(latestMessage).toBe('feat: relay commit') + }) + + // Why: covers the error-extraction path in commitChangesRelay + // (git-handler-worktree-ops.ts). Running `git commit` with nothing staged + // exits non-zero and writes a "nothing to commit" message; we assert the + // relay surfaces a non-empty error string so the UI can display it. + it('returns a non-empty error when the commit fails', async () => { + gitInit(tmpDir) + + const result = (await dispatcher.callRequest('git.commit', { + worktreePath: tmpDir, + message: 'no changes' + })) as { success: boolean; error?: string } + + expect(result.success).toBe(false) + expect(typeof result.error).toBe('string') + expect((result.error ?? '').length).toBeGreaterThan(0) + // Why: exact phrasing can vary across git versions, so match the + // stable substring "nothing" rather than the full "nothing to commit". + expect((result.error ?? '').toLowerCase()).toContain('nothing') + }) + }) + + describe('bulkStage and bulkUnstage', () => { + it('stages multiple files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b') + gitCommit(tmpDir, 'initial') + + writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified') + + await dispatcher.callRequest('git.bulkStage', { + worktreePath: tmpDir, + filePaths: ['a.txt', 'b.txt'] + }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output).toContain('a.txt') + expect(output).toContain('b.txt') + }) + + it('unstages multiple files', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + writeFileSync(path.join(tmpDir, 'b.txt'), 'b') + gitCommit(tmpDir, 'initial') + + writeFileSync(path.join(tmpDir, 'a.txt'), 'changed') + writeFileSync(path.join(tmpDir, 'b.txt'), 'changed') + execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) + + await dispatcher.callRequest('git.bulkUnstage', { + worktreePath: tmpDir, + filePaths: ['a.txt', 'b.txt'] + }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe('') + }) + }) +}) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts new file mode 100644 index 00000000000..84990e39da0 --- /dev/null +++ b/src/relay/git-handler-status-ops.ts @@ -0,0 +1,95 @@ +/** + * Status and conflict-detection operations extracted from git-handler.ts. + * + * Why: oxlint max-lines (300) requires splitting large files. + * These functions are pure data operations on git state — no class coupling. + */ +import * as path from 'path' +import { existsSync } from 'fs' +import { readFile } from 'fs/promises' +import { parseStatusOutput, parseUnmergedEntry } from './git-handler-utils' +import type { GitExec } from './git-handler-ops' + +export async function resolveGitDir(worktreePath: string): Promise { + const dotGitPath = path.join(worktreePath, '.git') + try { + const contents = await readFile(dotGitPath, 'utf-8') + const match = contents.match(/^gitdir:\s*(.+)\s*$/m) + if (match) { + return path.resolve(worktreePath, match[1]) + } + } catch { + // .git is a directory, not a file + } + return dotGitPath +} + +export async function detectConflictOperation(worktreePath: string): Promise { + const gitDir = await resolveGitDir(worktreePath) + try { + if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) { + return 'merge' + } + if ( + existsSync(path.join(gitDir, 'rebase-merge')) || + existsSync(path.join(gitDir, 'rebase-apply')) + ) { + return 'rebase' + } + if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) { + return 'cherry-pick' + } + } catch { + // fs error — treat as no conflict operation + } + return 'unknown' +} + +export async function getStatusOp( + git: GitExec, + params: Record +): Promise<{ + entries: Record[] + conflictOperation: string + head?: string + branch?: string +}> { + const worktreePath = params.worktreePath as string + const conflictOperation = await detectConflictOperation(worktreePath) + const entries: Record[] = [] + let head: string | undefined + let branch: string | undefined + + try { + // Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8 in + // git's stdout instead of C-style octal escapes; without it the parsed + // entry.path renders as gibberish in the source-control sidebar and + // downstream blob lookups miss. + const { stdout } = await git( + [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ], + worktreePath + ) + const parsed = parseStatusOutput(stdout) + entries.push(...parsed.entries) + head = parsed.head + branch = parsed.branch + + for (const uLine of parsed.unmergedLines) { + const entry = parseUnmergedEntry(worktreePath, uLine) + if (entry) { + entries.push(entry) + } + } + } catch { + // not a git repo or git not available + } + + return { entries, conflictOperation, head, branch } +} diff --git a/src/relay/git-handler-test-setup.ts b/src/relay/git-handler-test-setup.ts new file mode 100644 index 00000000000..8e5dc7ef0c3 --- /dev/null +++ b/src/relay/git-handler-test-setup.ts @@ -0,0 +1,76 @@ +/** + * Shared test utilities for git-handler tests. + * + * Why: oxlint max-lines (300) requires splitting large test suites. + * This module exports the mock dispatcher factory and git helpers + * so multiple test files can reuse them without duplication. + */ +import { vi } from 'vitest' +import { execFileSync } from 'child_process' +import type { RelayDispatcher } from './dispatcher' + +// Why: declare an explicit type so the inferred return type of +// createMockDispatcher doesn't transitively reference `@vitest/spy`'s +// internal `Procedure` type (from `vi.fn(...)`). Without this annotation, +// TS2883 fires under `pnpm run tc:node` because the generated .d.ts would +// need to name a type that isn't portably resolvable from this module. +export type MockDispatcher = { + onRequest: ( + method: string, + handler: ( + params: Record, + context: { isStale: () => boolean } + ) => Promise + ) => void + onNotification: (method: string, handler: (params: Record) => void) => void + notify: (method: string, params?: Record) => void + _requestHandlers: Map< + string, + (params: Record, context: { isStale: () => boolean }) => Promise + > + callRequest(method: string, params?: Record): Promise +} + +export function createMockDispatcher(): MockDispatcher { + const requestHandlers = new Map< + string, + (params: Record, context: { isStale: () => boolean }) => Promise + >() + + return { + onRequest: vi.fn( + ( + method: string, + handler: ( + params: Record, + context: { isStale: () => boolean } + ) => Promise + ) => { + requestHandlers.set(method, handler) + } + ), + onNotification: vi.fn(), + notify: vi.fn(), + _requestHandlers: requestHandlers, + async callRequest(method: string, params: Record = {}) { + const handler = requestHandlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + return handler(params, { isStale: () => false }) + } + } +} + +export function gitInit(dir: string): void { + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }) +} + +export function gitCommit(dir: string, message: string): void { + execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) +} + +export type { RelayDispatcher } diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index 292ee494e6f..d511197363e 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -72,15 +72,30 @@ export function parseConflictKind(xy: string): string | null { export function parseStatusOutput(stdout: string): { entries: Record[] unmergedLines: string[] + head?: string + branch?: string } { const entries: Record[] = [] const unmergedLines: string[] = [] + let head: string | undefined + let branch: string | undefined for (const line of stdout.split(/\r?\n/)) { if (!line) { continue } + if (line.startsWith('# branch.oid ')) { + head = line.slice('# branch.oid '.length).trim() + continue + } + + if (line.startsWith('# branch.head ')) { + const branchHead = line.slice('# branch.head '.length).trim() + branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : '' + continue + } + if (line.startsWith('1 ') || line.startsWith('2 ')) { const parts = line.split(' ') const xy = parts[1] @@ -130,7 +145,7 @@ export function parseStatusOutput(stdout: string): { } } - return { entries, unmergedLines } + return { entries, unmergedLines, head, branch } } /** diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts new file mode 100644 index 00000000000..ea7a0684cea --- /dev/null +++ b/src/relay/git-handler-worktree-ops.ts @@ -0,0 +1,133 @@ +/** + * Worktree management and commit operations for the relay git handler. + * + * Why: extracted from git-handler-ops.ts to keep all relay files under + * the oxlint max-lines (300) limit. + */ +import * as path from 'path' +import type { GitExec } from './git-handler-ops' + +// ─── Worktree management ───────────────────────────────────────────── + +export async function addWorktreeOp(git: GitExec, params: Record): Promise { + const repoPath = params.repoPath as string + const branchName = params.branchName as string + const targetDir = params.targetDir as string + const base = params.base as string | undefined + + // Why: a branchName starting with '-' would be interpreted as a git flag, + // potentially changing the command's semantics (e.g. "--detach"). + if (branchName.startsWith('-') || (base && base.startsWith('-'))) { + throw new Error('Branch name and base ref must not start with "-"') + } + + // Why: --no-track + push.autoSetupRemote=true mirrors the local + // addWorktree path (src/main/git/worktree.ts). Keeping the SSH path in + // sync prevents a transport-only divergence where "Orca creates a + // worktree" produces a different `git status` / `git push` UX based on + // whether the repo is local or SSH-mounted. See full design rationale + // (state machine, common-dir scope, old-git fallback) in the comments + // around src/main/git/worktree.ts addWorktree — those invariants apply + // identically here. + const args = ['worktree', 'add', '--no-track', '-b', branchName, targetDir] + if (base) { + args.push(base) + } + + await git(args, repoPath) + + // Why: best-effort write so a deliberate user value (any scope) is + // preserved and a real read failure is not silently overwritten. Final + // catch is warn-only — if the remote host's git is <2.37 the value is + // ignored at push time and the user falls back to `git push -u` once. + // (Note: it is the SSH host's git that matters here, not the client's.) + // Mirrors local addWorktree exactly. + try { + let alreadySet = false + try { + await git(['config', '--get', 'push.autoSetupRemote'], targetDir) + alreadySet = true + } catch (readError) { + // Why: `git config --get` exits 1 only when the key is unset at every + // scope. Any other code is a real read failure (corrupt config, + // locked file) — surface it via the outer catch instead of falling + // through to overwrite the user's actual value. + const code = (readError as { code?: unknown })?.code + if (code !== 1) { + throw readError + } + } + if (!alreadySet) { + await git(['config', '--local', 'push.autoSetupRemote', 'true'], targetDir) + } + } catch (error) { + console.warn(`relay addWorktree: failed to set push.autoSetupRemote for ${targetDir}`, error) + } +} + +export async function removeWorktreeOp( + git: GitExec, + params: Record +): Promise { + const worktreePath = params.worktreePath as string + const force = params.force as boolean | undefined + + let repoPath = worktreePath + try { + const { stdout } = await git(['rev-parse', '--git-common-dir'], worktreePath) + const commonDir = stdout.trim() + if (commonDir && commonDir !== '.git') { + repoPath = path.resolve(worktreePath, commonDir, '..') + } + } catch { + // fall through with worktreePath as repo + } + + const args = ['worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(worktreePath) + await git(args, repoPath) + await git(['worktree', 'prune'], repoPath) +} + +// ─── Commit ────────────────────────────────────────────────────────── + +export async function commitChangesRelay( + git: GitExec, + worktreePath: string, + message: string +): Promise<{ success: boolean; error?: string }> { + // Why: defense-in-depth. The IPC handler at src/main/ipc/filesystem.ts validates + // the message, but a relay caller (future automation, or an SSH client connecting + // to the relay directly) could bypass that path. Reject empty/whitespace messages + // here so we surface a clear error instead of git's opaque failure. + if (typeof message !== 'string' || message.trim().length === 0) { + return { success: false, error: 'Commit message is required' } + } + + try { + await git(['commit', '-m', message], worktreePath) + return { success: true } + } catch (error) { + // Why: surface whichever channel carries the useful message. Pre-commit/GPG + // hook failures write to stderr; "nothing to commit, working tree clean" + // writes to stdout. Try stderr first, fall back to stdout, then error.message. + // Mirrors commitChanges in src/main/git/status.ts — keep the two paths in sync. + const readStringField = (field: string): string | null => { + if (typeof error === 'object' && error && field in error) { + const v = (error as Record)[field] + if (typeof v === 'string' && v.length > 0) { + return v + } + } + return null + } + const errorMessage = + readStringField('stderr') ?? + readStringField('stdout') ?? + (error instanceof Error ? error.message : 'Commit failed') + return { success: false, error: errorMessage } + } +} diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 1cf6ff160fe..174164b8bda 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -1,58 +1,32 @@ +/* eslint-disable max-lines -- Why: this file covers ~14 distinct relay git + handlers plus the addWorktree state machine (--no-track + push.autoSetupRemote + probe/write across four flow branches). Splitting per-handler would scatter + related coverage without a meaningful boundary. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { GitHandler } from './git-handler' import { RelayContext } from './context' -import type { RelayDispatcher } from './dispatcher' import * as fs from 'fs/promises' import * as path from 'path' -import { mkdtempSync, writeFileSync } from 'fs' +import { mkdtempSync, mkdirSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { execFileSync } from 'child_process' - -function createMockDispatcher() { - const requestHandlers = new Map) => Promise>() - const notificationHandlers = new Map) => void>() - - return { - onRequest: vi.fn( - (method: string, handler: (params: Record) => Promise) => { - requestHandlers.set(method, handler) - } - ), - onNotification: vi.fn((method: string, handler: (params: Record) => void) => { - notificationHandlers.set(method, handler) - }), - notify: vi.fn(), - _requestHandlers: requestHandlers, - async callRequest(method: string, params: Record = {}) { - const handler = requestHandlers.get(method) - if (!handler) { - throw new Error(`No handler for ${method}`) - } - return handler(params) - } - } -} - -function gitInit(dir: string): void { - execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' }) -} - -function gitCommit(dir: string, message: string): void { - execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' }) - execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' }) -} +import { + createMockDispatcher, + gitInit, + gitCommit, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' describe('GitHandler', () => { - let dispatcher: ReturnType + let dispatcher: MockDispatcher let tmpDir: string beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-')) dispatcher = createMockDispatcher() const ctx = new RelayContext() - ctx.registerRoot(tmpDir) + // eslint-disable-next-line no-new new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) }) @@ -63,6 +37,7 @@ describe('GitHandler', () => { it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('git.status') + expect(methods).toContain('git.commit') expect(methods).toContain('git.diff') expect(methods).toContain('git.stage') expect(methods).toContain('git.unstage') @@ -71,10 +46,16 @@ describe('GitHandler', () => { expect(methods).toContain('git.discard') expect(methods).toContain('git.conflictOperation') expect(methods).toContain('git.branchCompare') + expect(methods).toContain('git.upstreamStatus') + expect(methods).toContain('git.fetch') + expect(methods).toContain('git.push') + expect(methods).toContain('git.pull') expect(methods).toContain('git.branchDiff') expect(methods).toContain('git.listWorktrees') expect(methods).toContain('git.addWorktree') expect(methods).toContain('git.removeWorktree') + expect(methods).toContain('git.exec') + expect(methods).toContain('git.isGitRepo') }) describe('status', () => { @@ -86,9 +67,13 @@ describe('GitHandler', () => { const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { entries: Record[] conflictOperation: string + head?: string + branch?: string } expect(result.entries).toEqual([]) expect(result.conflictOperation).toBe('unknown') + expect(result.branch).toMatch(/^refs\/heads\//) + expect(typeof result.head).toBe('string') }) it('detects untracked files', async () => { @@ -135,6 +120,51 @@ describe('GitHandler', () => { expect(staged).toBeDefined() expect(staged!.status).toBe('modified') }) + + // Why: regression for issue #1503 — git's default core.quotePath=true + // emits non-ASCII paths as octal-escaped, double-quoted strings (e.g. + // "docs/\346\227\245\346\234\254\350\252\236/sample.md"), which made the + // sidebar show gibberish and broke downstream blob reads. + it('preserves UTF-8 paths in status output', async () => { + gitInit(tmpDir) + const utf8Dir = path.join(tmpDir, 'docs', '日本語') + mkdirSync(utf8Dir, { recursive: true }) + writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello') + + const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { + entries: Record[] + } + const entry = result.entries.find((e) => + typeof e.path === 'string' ? e.path.endsWith('sample.md') : false + ) + expect(entry).toBeDefined() + expect(entry!.path).toBe('docs/日本語/sample.md') + }) + + // Why: regression for issue #1503 on the porcelain v2 type-1 entry parser + // branch (tracked + modified). The existing UTF-8 test exercises only the + // untracked '?' branch; this one exercises the path-reconstruction code in + // parseStatusOutput that joins parts.slice(8). + it('preserves UTF-8 paths for tracked-modified entries', async () => { + gitInit(tmpDir) + const utf8Dir = path.join(tmpDir, 'docs', '日本語') + mkdirSync(utf8Dir, { recursive: true }) + const utf8File = path.join(utf8Dir, 'sample.md') + writeFileSync(utf8File, 'original') + gitCommit(tmpDir, 'initial') + writeFileSync(utf8File, 'modified') + + const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { + entries: Record[] + } + const entry = result.entries.find((e) => + typeof e.path === 'string' ? e.path.endsWith('sample.md') : false + ) + expect(entry).toBeDefined() + expect(entry!.path).toBe('docs/日本語/sample.md') + expect(entry!.status).toBe('modified') + expect(entry!.area).toBe('unstaged') + }) }) describe('stage and unstage', () => { @@ -269,6 +299,193 @@ describe('GitHandler', () => { expect(result.summary.commitsAhead).toBe(1) } }) + + // Why: regression for issue #1503 on the branch-diff path. Without + // -c core.quotePath=false the diff --name-status output is octal-escaped, + // which broke the "Committed on branch" file list. + it('preserves UTF-8 paths in branch-compare entries', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + + // Capture the default branch name before switching, so the test works + // regardless of whether git's init.defaultBranch is master or main. + const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + + execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + const utf8Dir = path.join(tmpDir, 'docs', '日本語') + mkdirSync(utf8Dir, { recursive: true }) + writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello') + gitCommit(tmpDir, 'feature commit') + + const result = (await dispatcher.callRequest('git.branchCompare', { + worktreePath: tmpDir, + baseRef + })) as { summary: Record; entries: Record[] } + + expect(result.summary.status).toBe('ready') + const entry = result.entries.find((e) => + typeof e.path === 'string' ? e.path.endsWith('sample.md') : false + ) + expect(entry).toBeDefined() + expect(entry!.path).toBe('docs/日本語/sample.md') + }) + }) + + describe('branchDiff', () => { + // Why: regression for issue #1503 on git.branchDiff. The branchCompare test + // covers loadBranchChanges in git-handler.ts, but branchDiffEntries in + // git-handler-ops.ts is a separate code path that also passes + // -c core.quotePath=false and must round-trip UTF-8. + it('preserves UTF-8 paths in branch-diff entries', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + + const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + + execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + const utf8Dir = path.join(tmpDir, 'docs', '日本語') + mkdirSync(utf8Dir, { recursive: true }) + writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello') + gitCommit(tmpDir, 'feature commit') + + const result = (await dispatcher.callRequest('git.branchDiff', { + worktreePath: tmpDir, + baseRef, + filePath: 'docs/日本語/sample.md' + })) as Record[] + + // Without includePatch, branchDiffEntries returns one stub entry per + // changed file. Asserting length===1 confirms the filter matched the + // raw UTF-8 path emitted by `git diff --name-status` — if quotePath + // were left at default, the entry's path would be the octal-quoted + // form and the filter at git-handler-ops.ts:230-237 would not match. + expect(result).toHaveLength(1) + }) + }) + + describe('remote operations', () => { + it('returns upstream divergence for tracked branches', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + + const result = (await dispatcher.callRequest('git.upstreamStatus', { + worktreePath: tmpDir + })) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number } + + expect(result.hasUpstream).toBe(false) + expect(result.ahead).toBe(0) + expect(result.behind).toBe(0) + }) + + it('reports ahead/behind counts against a real upstream remote', async () => { + // Why: the upstream branch exists but isn't configured — exercise the + // full path through `git rev-parse HEAD@{u}` + `rev-list --left-right` + // so a future refactor can't silently break the happy-path roundtrip + // the no-upstream test doesn't cover. + const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) + try { + execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) + + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + + execFileSync('git', ['remote', 'add', 'origin', bareDir], { + cwd: tmpDir, + stdio: 'pipe' + }) + execFileSync('git', ['push', '--set-upstream', 'origin', branch], { + cwd: tmpDir, + stdio: 'pipe' + }) + + // Add two local commits (ahead=2), then reset behind the remote tip + // and add one different commit so we end up ahead=1, behind=0 vs. + // upstream; then reset to first commit to produce behind=1 ahead=0. + writeFileSync(path.join(tmpDir, 'ahead1.txt'), 'a1') + gitCommit(tmpDir, 'ahead1') + writeFileSync(path.join(tmpDir, 'ahead2.txt'), 'a2') + gitCommit(tmpDir, 'ahead2') + // Push so remote is at ahead2 (so after we reset below, we are behind). + execFileSync('git', ['push', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' }) + // Reset local back to the first commit: 0 ahead, 2 behind. + execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' }) + + const result = (await dispatcher.callRequest('git.upstreamStatus', { + worktreePath: tmpDir + })) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number } + + expect(result.hasUpstream).toBe(true) + expect(result.upstreamName).toBe(`origin/${branch}`) + expect(result.ahead).toBe(0) + expect(result.behind).toBe(2) + } finally { + await fs.rm(bareDir, { recursive: true, force: true }) + } + }) + + it('fetches from a configured remote without throwing', async () => { + const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) + try { + execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) + + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['remote', 'add', 'origin', bareDir], { + cwd: tmpDir, + stdio: 'pipe' + }) + execFileSync('git', ['push', '--set-upstream', 'origin', branch], { + cwd: tmpDir, + stdio: 'pipe' + }) + + await expect( + dispatcher.callRequest('git.fetch', { worktreePath: tmpDir }) + ).resolves.not.toThrow() + + // FETCH_HEAD is created by any successful fetch, confirming the + // remote was actually contacted (not just silently no-op'd). + await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined() + } finally { + await fs.rm(bareDir, { recursive: true, force: true }) + } + }) + + it('rethrows upstreamStatus failures that are not "no upstream configured"', async () => { + // Why: the handler's catch is narrowed to only swallow the expected + // "no upstream" signal. A non-repo path should surface its error rather + // than silently returning hasUpstream=false, which would mask auth or + // corruption failures in production. + const nonRepoDir = path.join(tmpDir, 'not-a-repo') + await fs.mkdir(nonRepoDir, { recursive: true }) + + await expect( + dispatcher.callRequest('git.upstreamStatus', { worktreePath: nonRepoDir }) + ).rejects.toThrow(/not a git repository/i) + }) }) describe('listWorktrees', () => { @@ -285,49 +502,170 @@ describe('GitHandler', () => { }) }) - describe('bulkStage and bulkUnstage', () => { - it('stages multiple files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - gitCommit(tmpDir, 'initial') + describe('addWorktree', () => { + // Why: relay handler tests for addWorktree use a mock-injection approach + // to deterministically control git exit codes (in particular `--get` exit + // 1 vs other non-zero codes) without relying on the test host's global + // git config. Mirrors the pattern in src/main/git/worktree.test.ts. + function setupMockedHandler(roots: string[]) { + const ctx = new RelayContext() + for (const r of roots) { + ctx.registerRoot(r) + } + const localDispatcher = createMockDispatcher() + const handler = new GitHandler(localDispatcher as unknown as RelayDispatcher, ctx) + const gitMock = + vi.fn< + ( + args: string[], + cwd: string, + opts?: { maxBuffer?: number } + ) => Promise<{ stdout: string; stderr: string }> + >() + ;(handler as unknown as { git: typeof gitMock }).git = gitMock + return { localDispatcher, gitMock } + } - writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified') + it('passes --no-track and writes push.autoSetupRemote when unset', async () => { + const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set - await dispatcher.callRequest('git.bulkStage', { - worktreePath: tmpDir, - filePaths: ['a.txt', 'b.txt'] + await localDispatcher.callRequest('git.addWorktree', { + repoPath: '/relay/repo', + branchName: 'feature/test', + targetDir: '/relay/wt', + base: 'origin/main' }) - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' - }) - expect(output).toContain('a.txt') - expect(output).toContain('b.txt') + expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ + ['worktree', 'add', '--no-track', '-b', 'feature/test', '/relay/wt', 'origin/main'], + ['config', '--get', 'push.autoSetupRemote'], + ['config', '--local', 'push.autoSetupRemote', 'true'] + ]) + // cwd for worktree add is repoPath; cwd for config calls is targetDir. + expect(gitMock.mock.calls[0]?.[1]).toBe('/relay/repo') + expect(gitMock.mock.calls[1]?.[1]).toBe('/relay/wt') + expect(gitMock.mock.calls[2]?.[1]).toBe('/relay/wt') }) - it('unstages multiple files', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'a.txt'), 'a') - writeFileSync(path.join(tmpDir, 'b.txt'), 'b') - gitCommit(tmpDir, 'initial') + it('preserves an existing push.autoSetupRemote value (does not overwrite user-set false)', async () => { + const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: 'false\n', stderr: '' }) // --get returns value - writeFileSync(path.join(tmpDir, 'a.txt'), 'changed') - writeFileSync(path.join(tmpDir, 'b.txt'), 'changed') - execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) - - await dispatcher.callRequest('git.bulkUnstage', { - worktreePath: tmpDir, - filePaths: ['a.txt', 'b.txt'] + await localDispatcher.callRequest('git.addWorktree', { + repoPath: '/relay/repo', + branchName: 'feature/preserve', + targetDir: '/relay/wt', + base: 'main' }) - const output = execFileSync('git', ['diff', '--cached', '--name-only'], { - cwd: tmpDir, - encoding: 'utf-8' + // No --local set: --get succeeded so we preserve the user's value. + expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ + ['worktree', 'add', '--no-track', '-b', 'feature/preserve', '/relay/wt', 'main'], + ['config', '--get', 'push.autoSetupRemote'] + ]) + }) + + it('treats --get success with empty stdout as "already set" (key present but blank)', async () => { + // Why: `git config --get key` exits 0 if the key has any value at any + // scope, including an explicitly empty string. We must not fall through + // to `--local set true` and overwrite that. Mirrors the local addWorktree + // parity case in src/main/git/worktree.test.ts. + const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --get success, empty value + + await localDispatcher.callRequest('git.addWorktree', { + repoPath: '/relay/repo', + branchName: 'feature/empty', + targetDir: '/relay/wt', + base: 'main' }) - expect(output.trim()).toBe('') + + expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ + ['worktree', 'add', '--no-track', '-b', 'feature/empty', '/relay/wt', 'main'], + ['config', '--get', 'push.autoSetupRemote'] + ]) + }) + + it('does not write --local when --get fails with non-unset code (corrupt config)', async () => { + // Why: exit 1 from `git config --get` means "key unset" — anything else + // is a real read failure (parse error, locked file). We must NOT fall + // through to `--local set true`, which would silently overwrite + // whatever value the user actually has. + const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get non-unset + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + localDispatcher.callRequest('git.addWorktree', { + repoPath: '/relay/repo', + branchName: 'feature/corrupt', + targetDir: '/relay/wt', + base: 'main' + }) + ).resolves.toBeUndefined() + + expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ + ['worktree', 'add', '--no-track', '-b', 'feature/corrupt', '/relay/wt', 'main'], + ['config', '--get', 'push.autoSetupRemote'] + ]) + expect(warnSpy).toHaveBeenCalledWith( + 'relay addWorktree: failed to set push.autoSetupRemote for /relay/wt', + expect.any(Error) + ) + warnSpy.mockRestore() + }) + + it('warns but resolves when --local set fails (write-failure is warn-only)', async () => { + const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset + gitMock.mockRejectedValueOnce(new Error('config locked')) // --local set fails + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + localDispatcher.callRequest('git.addWorktree', { + repoPath: '/relay/repo', + branchName: 'feature/writefail', + targetDir: '/relay/wt', + base: 'main' + }) + ).resolves.toBeUndefined() + + expect(warnSpy).toHaveBeenCalledWith( + 'relay addWorktree: failed to set push.autoSetupRemote for /relay/wt', + expect.any(Error) + ) + warnSpy.mockRestore() + }) + + it('does not probe or write config when worktree add itself fails', async () => { + // Why: a refactor that moves the config block earlier could try to + // probe against a worktree directory that was never created. Pin the + // ordering invariant: config calls happen only after worktree add + // succeeds. + const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) + gitMock.mockRejectedValueOnce(new Error('worktree add failed')) + + await expect( + localDispatcher.callRequest('git.addWorktree', { + repoPath: '/relay/repo', + branchName: 'feature/fail', + targetDir: '/relay/wt', + base: 'main' + }) + ).rejects.toThrow('worktree add failed') + + expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ + ['worktree', 'add', '--no-track', '-b', 'feature/fail', '/relay/wt', 'main'] + ]) }) }) }) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 7e312bea072..79de7175c82 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,23 +1,20 @@ import { execFile } from 'child_process' import { promisify } from 'util' -import { existsSync } from 'fs' -import { readFile, rm } from 'fs/promises' +import { rm } from 'fs/promises' import * as path from 'path' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' -import { - parseStatusOutput, - parseUnmergedEntry, - parseBranchDiff, - parseWorktreeList -} from './git-handler-utils' +import { parseBranchDiff, parseWorktreeList } from './git-handler-utils' import { computeDiff, branchCompare as branchCompareOp, branchDiffEntries, validateGitExecArgs } from './git-handler-ops' +import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' +import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' +import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -25,16 +22,17 @@ const BULK_CHUNK_SIZE = 100 export class GitHandler { private dispatcher: RelayDispatcher - private context: RelayContext - constructor(dispatcher: RelayDispatcher, context: RelayContext) { + // Why: RelayContext is accepted for protocol back-compat (see + // docs/relay-fs-allowlist-removal.md) but no longer consulted on git ops. + constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher - this.context = context this.registerHandlers() } private registerHandlers(): void { this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.commit', (p) => this.commit(p)) this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p)) this.dispatcher.onRequest('git.stage', (p) => this.stage(p)) this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p)) @@ -43,6 +41,10 @@ export class GitHandler { this.dispatcher.onRequest('git.discard', (p) => this.discard(p)) this.dispatcher.onRequest('git.conflictOperation', (p) => this.conflictOperation(p)) this.dispatcher.onRequest('git.branchCompare', (p) => this.branchCompare(p)) + this.dispatcher.onRequest('git.upstreamStatus', (p) => this.upstreamStatus(p)) + this.dispatcher.onRequest('git.fetch', (p) => this.fetch(p)) + this.dispatcher.onRequest('git.push', (p) => this.push(p)) + this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) @@ -73,71 +75,11 @@ export class GitHandler { } private async getStatus(params: Record) { - const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) - const conflictOperation = await this.detectConflictOperation(worktreePath) - const entries: Record[] = [] - - try { - const { stdout } = await this.git( - ['status', '--porcelain=v2', '--untracked-files=all'], - worktreePath - ) - - const parsed = parseStatusOutput(stdout) - entries.push(...parsed.entries) - - for (const uLine of parsed.unmergedLines) { - const entry = parseUnmergedEntry(worktreePath, uLine) - if (entry) { - entries.push(entry) - } - } - } catch { - // Not a git repo or git not available - } - - return { entries, conflictOperation } - } - - private async detectConflictOperation(worktreePath: string): Promise { - const gitDir = await this.resolveGitDir(worktreePath) - try { - if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) { - return 'merge' - } - if ( - existsSync(path.join(gitDir, 'rebase-merge')) || - existsSync(path.join(gitDir, 'rebase-apply')) - ) { - return 'rebase' - } - if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) { - return 'cherry-pick' - } - } catch { - // fs error - } - return 'unknown' - } - - private async resolveGitDir(worktreePath: string): Promise { - const dotGitPath = path.join(worktreePath, '.git') - try { - const contents = await readFile(dotGitPath, 'utf-8') - const match = contents.match(/^gitdir:\s*(.+)\s*$/m) - if (match) { - return path.resolve(worktreePath, match[1]) - } - } catch { - // .git is a directory - } - return dotGitPath + return getStatusOp(this.git.bind(this), params) } private async getDiff(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) const filePath = params.filePath as string // Why: filePath is relative to worktreePath and used in readWorkingFile via // path.join. Without validation, ../../etc/passwd traverses outside the worktree. @@ -157,21 +99,26 @@ export class GitHandler { private async stage(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) const filePath = params.filePath as string await this.git(['add', '--', filePath], worktreePath) } + private async commit( + params: Record + ): Promise<{ success: boolean; error?: string }> { + const worktreePath = params.worktreePath as string + const message = params.message as string + return commitChangesRelay(this.git.bind(this), worktreePath, message) + } + private async unstage(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) const filePath = params.filePath as string await this.git(['restore', '--staged', '--', filePath], worktreePath) } private async bulkStage(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) const filePaths = params.filePaths as string[] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) @@ -181,7 +128,6 @@ export class GitHandler { private async bulkUnstage(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) const filePaths = params.filePaths as string[] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) @@ -191,7 +137,6 @@ export class GitHandler { private async discard(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) const filePath = params.filePath as string const resolved = path.resolve(worktreePath, filePath) @@ -210,26 +155,18 @@ export class GitHandler { // untracked } - if (tracked) { - await this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) - } else { - // Why: textual path checks pass for symlinks inside the worktree, but - // rm follows symlinks — so a symlink pointing outside the workspace - // would delete the target. validatePathResolved catches this. - await this.context.validatePathResolved(resolved) - await rm(resolved, { force: true, recursive: true }) - } + await (tracked + ? this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) + : rm(resolved, { force: true, recursive: true })) } private async conflictOperation(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) - return this.detectConflictOperation(worktreePath) + return detectConflictOperation(worktreePath) } private async branchCompare(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) const baseRef = params.baseRef as string // Why: a baseRef starting with '-' would be interpreted as a flag to // git rev-parse, potentially leaking environment variables or config. @@ -238,17 +175,112 @@ export class GitHandler { } const gitBound = this.git.bind(this) return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => { + // Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8; + // without it parseBranchDiff would yield C-style octal-escaped paths. const { stdout } = await gitBound( - ['diff', '--name-status', '-M', '-C', mergeBase, headOid], + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], worktreePath ) return parseBranchDiff(stdout) }) } + private async upstreamStatus(params: Record) { + const worktreePath = params.worktreePath as string + + try { + const { stdout: upstreamStdout } = await this.git( + ['rev-parse', '--abbrev-ref', 'HEAD@{u}'], + worktreePath + ) + const upstreamName = upstreamStdout.trim() + if (!upstreamName) { + return { hasUpstream: false, ahead: 0, behind: 0 } + } + const { stdout: countsStdout } = await this.git( + ['rev-list', '--left-right', '--count', 'HEAD...@{u}'], + worktreePath + ) + const tokens = countsStdout.trim().split(/\s+/) + if (tokens.length !== 2) { + // Why: 'rev-list --left-right --count HEAD...@{u}' must emit exactly two + // tokens; anything else (empty stdout, SSH transport truncation, unexpected + // locale) is a real failure and must not be silently reported as "in sync" 0/0. + throw new Error(`Unexpected git rev-list output: ${JSON.stringify(countsStdout)}`) + } + const ahead = Number.parseInt(tokens[0]!, 10) + const behind = Number.parseInt(tokens[1]!, 10) + if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) { + throw new Error(`Unparseable git rev-list counts: ${JSON.stringify(countsStdout)}`) + } + return { + hasUpstream: true, + upstreamName, + ahead, + behind + } + } catch (error) { + // Why: we only swallow the 'no upstream configured' error — that's an + // expected state, not a failure. Other errors (auth, corruption, network) + // should surface to the user so they can act on them. + if (isNoUpstreamError(error)) { + return { hasUpstream: false, ahead: 0, behind: 0 } + } + // Why: match fetch/push/pull normalization so execFile preamble and local + // paths don't leak to the renderer. + throw new Error(normalizeGitErrorMessage(error, 'upstream')) + } + } + + private async fetch(params: Record) { + const worktreePath = params.worktreePath as string + try { + await this.git(['fetch', '--prune'], worktreePath) + } catch (error) { + // Why: mirror the local gitFetch normalization so SSH users see the same + // actionable messages instead of raw git stderr (which varies across + // versions/locales and may embed credentials). + throw new Error(normalizeGitErrorMessage(error, 'fetch')) + } + } + + private async push(params: Record) { + const worktreePath = params.worktreePath as string + // Why: always pass --set-upstream (mirrors src/main/git/remote.ts). + // Orca's worktrees initially track the BASE ref (origin/main) because + // they're created via `git worktree add --track -b + // ` — without --set-upstream the local branch keeps tracking + // the base after the first push, so ahead/behind via @{u} measures + // "ahead of base" instead of "ahead of remote branch", and the UI's + // primary button never rotates from "Push" to "Commit". The `publish` + // flag is preserved in the param shape for IPC compatibility but is no + // longer load-bearing. On an already-published branch --set-upstream is + // a no-op for the tracking config. + void params.publish + try { + await this.git(['push', '--set-upstream', 'origin', 'HEAD'], worktreePath) + } catch (error) { + // Why: mirror the local gitPush normalization so SSH users see the same + // "non-fast-forward / pull first" guidance instead of raw git stderr. + throw new Error(normalizeGitErrorMessage(error, 'push')) + } + } + + private async pull(params: Record) { + const worktreePath = params.worktreePath as string + // Why: plain `git pull` uses the user's configured pull strategy (merge by + // default) so diverged branches reconcile instead of erroring out. + try { + await this.git(['pull'], worktreePath) + } catch (error) { + // Why: mirror the local gitPull normalization so SSH users see the same + // actionable messages instead of raw git stderr. + throw new Error(normalizeGitErrorMessage(error, 'pull')) + } + } + private async branchDiff(params: Record) { const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) const baseRef = params.baseRef as string if (baseRef.startsWith('-')) { throw new Error('Base ref must not start with "-"') @@ -269,16 +301,12 @@ export class GitHandler { private async exec(params: Record) { const args = params.args as string[] const cwd = params.cwd as string - this.context.validatePath(cwd) validateGitExecArgs(args) const { stdout, stderr } = await this.git(args, cwd) return { stdout, stderr } } - // Why: isGitRepo is called during the add-repo flow before any workspace - // roots are registered with the relay. Skipping validatePath is safe because - // this is a read-only git rev-parse check — no files are mutated. private async isGitRepo(params: Record) { const dirPath = params.dirPath as string try { @@ -291,7 +319,6 @@ export class GitHandler { private async listWorktrees(params: Record) { const repoPath = params.repoPath as string - this.context.validatePath(repoPath) try { const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath) return parseWorktreeList(stdout) @@ -301,54 +328,10 @@ export class GitHandler { } private async addWorktree(params: Record) { - const repoPath = params.repoPath as string - this.context.validatePath(repoPath) - const branchName = params.branchName as string - const targetDir = params.targetDir as string - this.context.validatePath(targetDir) - const base = params.base as string | undefined - const track = params.track as boolean | undefined - - // Why: a branchName starting with '-' would be interpreted as a git flag, - // potentially changing the command's semantics (e.g. "--detach"). - if (branchName.startsWith('-') || (base && base.startsWith('-'))) { - throw new Error('Branch name and base ref must not start with "-"') - } - - const args = ['worktree', 'add'] - if (track) { - args.push('--track') - } - args.push('-b', branchName, targetDir) - if (base) { - args.push(base) - } - - await this.git(args, repoPath) + return addWorktreeOp(this.git.bind(this), params) } private async removeWorktree(params: Record) { - const worktreePath = params.worktreePath as string - this.context.validatePath(worktreePath) - const force = params.force as boolean | undefined - - let repoPath = worktreePath - try { - const { stdout } = await this.git(['rev-parse', '--git-common-dir'], worktreePath) - const commonDir = stdout.trim() - if (commonDir && commonDir !== '.git') { - repoPath = path.resolve(worktreePath, commonDir, '..') - } - } catch { - // Fall through with worktreePath as repo - } - - const args = ['worktree', 'remove'] - if (force) { - args.push('--force') - } - args.push(worktreePath) - await this.git(args, repoPath) - await this.git(['worktree', 'prune'], repoPath) + return removeWorktreeOp(this.git.bind(this), params) } } diff --git a/src/relay/integration.test.ts b/src/relay/integration.test.ts index 50b167a04c7..1e69f9556c8 100644 --- a/src/relay/integration.test.ts +++ b/src/relay/integration.test.ts @@ -12,6 +12,7 @@ import { rm, readFile, stat } from 'fs/promises' import * as path from 'path' import { tmpdir } from 'os' import { execFileSync } from 'child_process' +import { randomBytes } from 'crypto' import { SshChannelMultiplexer, @@ -75,7 +76,6 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { // Register handlers on the relay const context = new RelayContext() - context.registerRoot(tmpDir) fsHandler = new FsHandler(dispatcher, context) new GitHandler(dispatcher, context) @@ -119,6 +119,15 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { expect(result.isBinary).toBe(false) }) + it('readFileStream round-trip preserves a 12 MB binary file', async () => { + const filePath = path.join(tmpDir, 'big.png') + const original = randomBytes(12 * 1024 * 1024) + writeFileSync(filePath, original) + const { readFileViaStream } = await import('../main/ssh/ssh-filesystem-stream-reader') + const { content } = await readFileViaStream(mux, filePath) + expect(Buffer.from(content, 'base64').equals(original)).toBe(true) + }, 30_000) + it('writeFile creates/overwrites file content', async () => { const filePath = path.join(tmpDir, 'output.txt') @@ -130,13 +139,10 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { it('stat returns file metadata', async () => { writeFileSync(path.join(tmpDir, 'sized.txt'), 'abcdef') - const result = (await mux.request('fs.stat', { filePath: path.join(tmpDir, 'sized.txt') })) as { size: number; type: string; mtime: number } - - expect(result.type).toBe('file') - expect(result.size).toBe(6) + expect(result).toMatchObject({ type: 'file', size: 6 }) expect(typeof result.mtime).toBe('number') }) diff --git a/src/relay/protocol-handshake.test.ts b/src/relay/protocol-handshake.test.ts new file mode 100644 index 00000000000..822fea3e20b --- /dev/null +++ b/src/relay/protocol-handshake.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + MessageType, + HEADER_LENGTH, + FrameDecoder, + encodeHandshakeFrame, + parseHandshakeMessage, + type DecodedFrame +} from './protocol' + +describe('handshake framing', () => { + it('round-trips an orca-relay-handshake envelope through the existing framing', () => { + const sent = encodeHandshakeFrame({ + type: 'orca-relay-handshake', + version: '0.1.0+deadbeef' + }) + expect(sent[0]).toBe(MessageType.Handshake) + expect(sent.length).toBeGreaterThan(HEADER_LENGTH) + + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + decoder.feed(sent) + + expect(frames).toHaveLength(1) + expect(frames[0].type).toBe(MessageType.Handshake) + const msg = parseHandshakeMessage(frames[0].payload) + expect(msg).toEqual({ type: 'orca-relay-handshake', version: '0.1.0+deadbeef' }) + }) + + it('round-trips an orca-relay-handshake-ok reply', () => { + const sent = encodeHandshakeFrame({ + type: 'orca-relay-handshake-ok', + version: '0.1.0+deadbeef' + }) + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + decoder.feed(sent) + const msg = parseHandshakeMessage(frames[0].payload) + expect(msg).toEqual({ type: 'orca-relay-handshake-ok', version: '0.1.0+deadbeef' }) + }) + + it('round-trips an orca-relay-handshake-mismatch reply', () => { + const sent = encodeHandshakeFrame({ + type: 'orca-relay-handshake-mismatch', + expected: '0.1.0+aaa', + got: '0.1.0+bbb' + }) + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + decoder.feed(sent) + const msg = parseHandshakeMessage(frames[0].payload) + expect(msg).toEqual({ + type: 'orca-relay-handshake-mismatch', + expected: '0.1.0+aaa', + got: '0.1.0+bbb' + }) + }) + + it('rejects payloads with unknown type', () => { + const bogus = Buffer.from(JSON.stringify({ type: 'orca-something-else', version: 'x' })) + expect(() => parseHandshakeMessage(bogus)).toThrow(/Unknown handshake type/) + }) + + it('handshake frames use a distinct MessageType from Regular and KeepAlive', () => { + expect(MessageType.Handshake).not.toBe(MessageType.Regular) + expect(MessageType.Handshake).not.toBe(MessageType.KeepAlive) + }) +}) diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index dfe9bda73a9..66818efaf61 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -9,12 +9,50 @@ export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024 export const MessageType = { Regular: 1, + Handshake: 2, KeepAlive: 9 } as const +// Why: a pre-dispatcher envelope on a freshly-accepted Unix socket. The daemon +// reads exactly one Handshake frame before attaching the JSON-RPC dispatcher, +// to refuse mismatched-version --connect bridges that would otherwise drive a +// stale daemon. +export type HandshakeMessage = + | { type: 'orca-relay-handshake'; version: string } + | { type: 'orca-relay-handshake-ok'; version: string } + | { type: 'orca-relay-handshake-mismatch'; expected: string; got: string } + +export function encodeHandshakeFrame(msg: HandshakeMessage): Buffer { + const payload = Buffer.from(JSON.stringify(msg), 'utf-8') + return encodeFrame(MessageType.Handshake, 0, 0, payload) +} + +export function parseHandshakeMessage(payload: Buffer): HandshakeMessage { + const msg = JSON.parse(payload.toString('utf-8')) as HandshakeMessage + const t = (msg as { type?: string }).type + if ( + t !== 'orca-relay-handshake' && + t !== 'orca-relay-handshake-ok' && + t !== 'orca-relay-handshake-mismatch' + ) { + throw new Error(`Unknown handshake type: ${t}`) + } + return msg +} + export const KEEPALIVE_SEND_MS = 5_000 export const TIMEOUT_MS = 20_000 +// ── Streaming constants (see docs/relay-file-stream-design.md) ───── + +export const STREAM_CHUNK_SIZE = 256 * 1024 +export const MAX_CONCURRENT_STREAMS = 16 + +export const RelayErrorCode = { + TooManyStreams: -33006, + StreamProtocolError: -33007 +} as const + export type JsonRpcRequest = { jsonrpc: '2.0' id: number @@ -125,6 +163,16 @@ export class FrameDecoder { reset(): void { this.buffer = Buffer.alloc(0) } + + // Why: at the handshake → dispatcher transition, the next consumer must + // pick up any bytes that arrived in the same TCP chunk as the handshake + // frame. This returns and clears the decoder's internal residue so the + // caller can hand it to the dispatcher (or stdout pipe) without loss. + drain(): Buffer { + const out = this.buffer + this.buffer = Buffer.alloc(0) + return out + } } export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { diff --git a/src/relay/relay-handshake-roundtrip.test.ts b/src/relay/relay-handshake-roundtrip.test.ts new file mode 100644 index 00000000000..28d884940b0 --- /dev/null +++ b/src/relay/relay-handshake-roundtrip.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { createServer, connect, type Server, type Socket } from 'net' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + setupDaemonHandshake, + runConnectHandshake, + EXIT_CODE_VERSION_MISMATCH +} from './relay-handshake' +import { + encodeHandshakeFrame, + encodeJsonRpcFrame, + FrameDecoder, + type DecodedFrame, + MessageType +} from './protocol' + +// Why: --connect normally calls process.exit on mismatch / fatal handshake +// errors. Stub it for tests so the harness sees a thrown sentinel error +// rather than tearing down the test runner. +class ExitCalled extends Error { + code: number + constructor(code: number) { + super(`process.exit(${code})`) + this.code = code + } +} + +describe('handshake round-trip over a real Socket pair', () => { + let server: Server + let sockPath: string + let tmpDir: string + let exitSpy: ReturnType + + let uncaughtHandler: (err: Error) => void + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-handshake-test-')) + sockPath = join(tmpDir, 'relay.sock') + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitCalled(code ?? 0) + }) as never) + // Why: process.exit is called from inside async callbacks + // (process.stderr.write flush callback) which would otherwise surface + // as an uncaughtException after the test resolves and tear down the + // runner. We swallow ExitCalled — exitSpy still records the call so + // assertions hold. + uncaughtHandler = (err: Error): void => { + if (err instanceof ExitCalled) { + return + } + throw err + } + process.on('uncaughtException', uncaughtHandler) + }) + + afterEach(async () => { + process.off('uncaughtException', uncaughtHandler) + exitSpy.mockRestore() + for (const s of liveServerSockets) { + s.destroy() + } + liveServerSockets.length = 0 + if (server) { + await new Promise((r) => server.close(() => r())) + } + rmSync(tmpDir, { recursive: true, force: true }) + }) + + const liveServerSockets: Socket[] = [] + function trackServerSocket(s: Socket): Socket { + liveServerSockets.push(s) + return s + } + + function startDaemon(version: string): Promise<{ + accepted: Promise<{ sock: Socket; leftover: Buffer }> + }> { + return new Promise((resolve) => { + const acceptedDeferred: { + promise: Promise<{ sock: Socket; leftover: Buffer }> + resolve: (v: { sock: Socket; leftover: Buffer }) => void + } = (() => { + let _resolve: (v: { sock: Socket; leftover: Buffer }) => void = () => {} + const promise = new Promise<{ sock: Socket; leftover: Buffer }>((r) => { + _resolve = r + }) + return { promise, resolve: _resolve } + })() + + server = createServer((sock) => { + trackServerSocket(sock) + setupDaemonHandshake(sock, { + launchVersion: version, + onAccepted: (s, leftover) => acceptedDeferred.resolve({ sock: s, leftover }) + }) + }) + server.listen(sockPath, () => resolve({ accepted: acceptedDeferred.promise })) + }) + } + + it('accepts a matching version and delivers no leftover when the bridge sent only the handshake', async () => { + const { accepted } = await startDaemon('0.1.0+match') + + const bridgeSock = connect(sockPath) + await new Promise((r) => bridgeSock.once('connect', () => r())) + + const acceptedCb = vi.fn<(leftover: Buffer) => void>() + runConnectHandshake(bridgeSock, '0.1.0+match', { onAccepted: acceptedCb }) + + const { leftover } = await accepted + expect(leftover.length).toBe(0) + + await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledTimes(1)) + expect(acceptedCb.mock.calls[0][0].length).toBe(0) + + bridgeSock.destroy() + }) + + it('preserves leftover bytes on the daemon side when an extra frame is coalesced after the handshake', async () => { + // Why: simulate an aggressive client that pipelines a frame immediately + // after the handshake. We bypass runConnectHandshake here and write the + // raw bytes directly so we control the coalescing behaviour. + const { accepted } = await startDaemon('0.1.0+match') + + const bridgeSock = connect(sockPath) + await new Promise((r) => bridgeSock.once('connect', () => r())) + + const handshakeFrame = encodeHandshakeFrame({ + type: 'orca-relay-handshake', + version: '0.1.0+match' + }) + const trailingPayload = encodeJsonRpcFrame({ jsonrpc: '2.0', method: 'noop', params: {} }, 1, 0) + bridgeSock.write(Buffer.concat([handshakeFrame, trailingPayload])) + + const { leftover } = await accepted + + const seen: DecodedFrame[] = [] + const dec = new FrameDecoder((f) => seen.push(f)) + dec.feed(leftover) + expect(seen).toHaveLength(1) + expect(seen[0].type).toBe(MessageType.Regular) + + bridgeSock.destroy() + }) + + it('preserves leftover bytes on the bridge side when the daemon coalesces handshake-ok + a JSON-RPC frame', async () => { + let serverHandshakeSeen = false + server = createServer((sock) => { + trackServerSocket(sock) + const decoder = new FrameDecoder((frame) => { + if (frame.type !== MessageType.Handshake || serverHandshakeSeen) { + return + } + serverHandshakeSeen = true + const ok = encodeHandshakeFrame({ + type: 'orca-relay-handshake-ok', + version: '0.1.0+match' + }) + const trailing = encodeJsonRpcFrame( + { jsonrpc: '2.0', method: 'pty.event', params: { evt: 'data' } }, + 7, + 1 + ) + sock.write(Buffer.concat([ok, trailing])) + }) + sock.on('data', (chunk: Buffer) => decoder.feed(chunk)) + }) + await new Promise((r) => server.listen(sockPath, () => r())) + + const bridgeSock = connect(sockPath) + await new Promise((r) => bridgeSock.once('connect', () => r())) + + const acceptedCb = vi.fn<(leftover: Buffer) => void>() + runConnectHandshake(bridgeSock, '0.1.0+match', { onAccepted: acceptedCb }) + + await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledTimes(1)) + const leftover = acceptedCb.mock.calls[0][0] + + const seen: DecodedFrame[] = [] + const dec = new FrameDecoder((f) => seen.push(f)) + dec.feed(leftover) + expect(seen).toHaveLength(1) + expect(seen[0].type).toBe(MessageType.Regular) + + bridgeSock.destroy() + }) + + it('exits with EXIT_CODE_VERSION_MISMATCH when the daemon reports a mismatch', async () => { + await startDaemon('0.1.0+server-version') + + const bridgeSock = connect(sockPath) + await new Promise((r) => bridgeSock.once('connect', () => r())) + + const acceptedCb = vi.fn() + runConnectHandshake(bridgeSock, '0.1.0+different', { onAccepted: acceptedCb }) + + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled()) + expect(exitSpy).toHaveBeenCalledWith(EXIT_CODE_VERSION_MISMATCH) + expect(acceptedCb).not.toHaveBeenCalled() + + bridgeSock.destroy() + }) + + it('does not call onAccepted before any handshake-ok frame arrives', async () => { + // Why: silent server that never replies. acceptedCb must stay + // un-invoked even though the bridge has flushed its handshake frame. + server = createServer((sock) => { + trackServerSocket(sock) + /* swallow */ + }) + await new Promise((r) => server.listen(sockPath, () => r())) + + const bridgeSock = connect(sockPath) + await new Promise((r) => bridgeSock.once('connect', () => r())) + + const acceptedCb = vi.fn() + runConnectHandshake(bridgeSock, '0.1.0+match', { onAccepted: acceptedCb }) + + await new Promise((r) => setTimeout(r, 100)) + expect(acceptedCb).not.toHaveBeenCalled() + + bridgeSock.destroy() + }) +}) diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts new file mode 100644 index 00000000000..653e03ddb92 --- /dev/null +++ b/src/relay/relay-handshake.ts @@ -0,0 +1,259 @@ +// Wire-level handshake helpers for the Orca relay. +// +// Why this lives in its own module: oxlint enforces a 300-line limit (with +// blanks/comments stripped) on .ts files, and relay.ts already runs near that +// limit. Splitting the version-handshake plumbing into a sibling module keeps +// relay.ts focused on the daemon-lifecycle wiring and makes the handshake +// independently unit-testable. + +import { dirname, join } from 'path' +import { existsSync, readFileSync, realpathSync } from 'fs' +import type { Socket } from 'net' +import { + RELAY_VERSION, + MessageType, + FrameDecoder, + encodeHandshakeFrame, + parseHandshakeMessage, + type DecodedFrame +} from './protocol' + +// Why: a unique exit code reserved for the wire-level version-mismatch terminal +// condition. The client (waitForSentinel + ssh.ts) maps this exit code to a +// non-retryable RelayVersionMismatchError so _onRelayLost skips the backoff +// loop. Any other non-zero exit is treated as a transient transport error. +export const EXIT_CODE_VERSION_MISMATCH = 42 + +// Why: the deploy step writes a content-hashed version marker (e.g. +// "0.1.0+0a5fe134d020") into ${remoteDir}/.version next to relay.js. Read it +// from the directory the running script lives in (NOT process.cwd()) so test +// spawns from arbitrary working dirs still report a coherent version. We +// resolve symlinks via realpathSync so a daemon launched indirectly (e.g. +// `node /tmp/symlink-to-relay.js`) still finds .version next to the real +// script. Falls back to bare RELAY_VERSION only if the file truly cannot be +// read; the wire handshake then refuses a fresh content-hashed client and +// the user gets a clean typed error rather than a silent stale-daemon loop. +export function readLaunchVersion(): string { + try { + const entry = process.argv[1] + let dir: string + if (entry) { + let resolved = entry + try { + resolved = realpathSync(entry) + } catch { + /* fall back to the unresolved path */ + } + dir = dirname(resolved) + } else { + dir = process.cwd() + } + const versionFile = join(dir, '.version') + if (existsSync(versionFile)) { + const v = readFileSync(versionFile, 'utf-8').trim() + if (v) { + return v + } + } + } catch { + /* fall through */ + } + return RELAY_VERSION +} + +// ── Daemon side ───────────────────────────────────────────────────── + +export type DaemonHandshakeCallbacks = { + // Why: leftover is any bytes the FrameDecoder buffered AFTER the handshake + // frame (e.g. the bridge wrote handshake + a JSON-RPC frame in the same + // TCP send). The caller MUST feed leftover into the dispatcher before + // attaching the new 'data' listener, otherwise those bytes are silently + // lost. + onAccepted: (sock: Socket, leftover: Buffer) => void + launchVersion: string +} + +// Why: pre-dispatcher version handshake. The daemon reads exactly one +// Handshake-typed frame off this freshly-accepted socket BEFORE the JSON-RPC +// dispatcher pipe is attached. Mismatch means the connecting bridge was +// launched against a different relay.js version than the daemon was; we close +// the socket so the bridge exits 42 and the client surfaces a typed error +// instead of looping over the dispatcher. +export function setupDaemonHandshake(sock: Socket, cb: DaemonHandshakeCallbacks): void { + let handshakeResolved = false + const decoder: FrameDecoder = new FrameDecoder( + (frame: DecodedFrame) => { + if (handshakeResolved) { + return + } + const accepted = handleDaemonHandshakeFrame(sock, frame, cb.launchVersion) + if (accepted) { + handshakeResolved = true + const leftover = decoder.drain() + detachHandshakeListener(sock) + cb.onAccepted(sock, leftover) + } + }, + (err) => { + process.stderr.write(`[relay] Handshake decode error: ${err.message}\n`) + sock.destroy() + } + ) + + const onHandshakeData = (chunk: Buffer): void => { + decoder.feed(chunk) + } + sock.on('data', onHandshakeData) + ;(sock as Socket & { __orcaOnHandshake?: typeof onHandshakeData }).__orcaOnHandshake = + onHandshakeData +} + +export function detachHandshakeListener(sock: Socket): void { + const tagged = sock as Socket & { __orcaOnHandshake?: (chunk: Buffer) => void } + if (tagged.__orcaOnHandshake) { + sock.removeListener('data', tagged.__orcaOnHandshake) + delete tagged.__orcaOnHandshake + } +} + +function handleDaemonHandshakeFrame( + sock: Socket, + frame: DecodedFrame, + launchVersion: string +): boolean { + if (frame.type !== MessageType.Handshake) { + process.stderr.write( + `[relay] Protocol violation pre-handshake: type=${frame.type}; closing socket\n` + ) + sock.destroy() + return false + } + let msg: ReturnType + try { + msg = parseHandshakeMessage(frame.payload) + } catch (err) { + process.stderr.write( + `[relay] Could not parse handshake: ${(err as Error).message}; closing socket\n` + ) + sock.destroy() + return false + } + if (msg.type !== 'orca-relay-handshake') { + process.stderr.write( + `[relay] Unexpected handshake type from client: ${msg.type}; closing socket\n` + ) + sock.destroy() + return false + } + if (msg.version !== launchVersion) { + process.stderr.write( + `[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket\n` + ) + try { + sock.write( + encodeHandshakeFrame({ + type: 'orca-relay-handshake-mismatch', + expected: launchVersion, + got: msg.version + }) + ) + } catch { + /* best-effort — close+exit-42 still wins */ + } + sock.end() + return false + } + process.stderr.write(`[relay] Handshake OK from version=${msg.version}\n`) + sock.write(encodeHandshakeFrame({ type: 'orca-relay-handshake-ok', version: launchVersion })) + return true +} + +// ── --connect side ────────────────────────────────────────────────── + +export type ConnectHandshakeCallbacks = { + // Why: leftover is any bytes the FrameDecoder buffered AFTER the + // handshake-ok frame. The caller MUST forward leftover to process.stdout + // (the SSH stdout pipe) before attaching the raw bridge, otherwise daemon + // bytes coalesced into the same TCP send as handshake-ok are silently + // dropped. + onAccepted: (leftover: Buffer) => void +} + +// Why: the wire-level version handshake from the bridge side. Before we attach +// the bidirectional pipe (and before we write RELAY_SENTINEL to stdout to +// unblock the client), we send a Handshake-typed frame carrying our version +// and wait for the daemon's Handshake response. This is defense-in-depth on +// top of the versioned-install-dir layout: a corrupt/missing .version, hash +// collision, or legacy-fallback path would otherwise let a v2 bridge drive a +// v1 daemon. VS Code's remoteExtensionHostAgentServer.ts:340 does the same +// check. +export function runConnectHandshake( + sock: Socket, + myVersion: string, + cb: ConnectHandshakeCallbacks +): void { + let handshakeDone = false + + const decoder: FrameDecoder = new FrameDecoder( + (frame: DecodedFrame) => { + if (handshakeDone) { + return + } + if (frame.type !== MessageType.Handshake) { + process.stderr.write( + `[relay-connect] Protocol violation: expected Handshake frame, got type=${frame.type}\n` + ) + sock.destroy() + process.exit(1) + } + let msg: ReturnType + try { + msg = parseHandshakeMessage(frame.payload) + } catch (err) { + process.stderr.write( + `[relay-connect] Could not parse handshake reply: ${(err as Error).message}\n` + ) + sock.destroy() + process.exit(1) + } + if (msg.type === 'orca-relay-handshake-ok') { + process.stderr.write(`[relay-connect] Handshake OK at version=${msg.version}\n`) + handshakeDone = true + const leftover = decoder.drain() + sock.removeAllListeners('data') + cb.onAccepted(leftover) + return + } + if (msg.type === 'orca-relay-handshake-mismatch') { + // Why: explicit stderr flush + exit so the diagnostic line is + // delivered to the client BEFORE the process exits. Without this, + // process.stderr writes can be buffered/async on pipe transports + // and parseHandshakeMismatchStderr loses the version detail. + process.stderr.write( + `[relay-connect] Handshake mismatch: expected=${msg.expected}, daemon=${msg.got}; exiting ${EXIT_CODE_VERSION_MISMATCH}\n`, + () => { + sock.destroy() + process.exit(EXIT_CODE_VERSION_MISMATCH) + } + ) + return + } + process.stderr.write(`[relay-connect] Unexpected handshake type: ${msg.type}\n`) + sock.destroy() + process.exit(1) + }, + (err) => { + process.stderr.write(`[relay-connect] Handshake decode error: ${err.message}\n`) + sock.destroy() + process.exit(1) + } + ) + + sock.on('data', (chunk: Buffer) => { + if (!handshakeDone) { + decoder.feed(chunk) + } + }) + + sock.write(encodeHandshakeFrame({ type: 'orca-relay-handshake', version: myVersion })) +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index aadf926d220..3df1555ee9b 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -15,6 +15,7 @@ import { homedir } from 'os' import { resolve, join } from 'path' import { unlinkSync, existsSync } from 'fs' import { RELAY_SENTINEL } from './protocol' +import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake' import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { PtyHandler } from './pty-handler' @@ -68,6 +69,7 @@ function parseArgs(argv: string[]): { // that owns the PTY sessions. function runConnectMode(sockPath: string): void { + const myVersion = readLaunchVersion() const sock = createConnection({ path: sockPath }) const connectTimeout = setTimeout(() => { @@ -78,13 +80,27 @@ function runConnectMode(sockPath: string): void { sock.on('connect', () => { clearTimeout(connectTimeout) - // Why: the client-side waitForSentinel expects this exact string - // before it starts sending framed data. Emitting it here lets the - // deploy code use the same sentinel-detection path for both fresh - // launches and reconnects. - process.stdout.write(RELAY_SENTINEL) - process.stdin.pipe(sock) - sock.pipe(process.stdout) + runConnectHandshake(sock, myVersion, { + onAccepted: (leftover: Buffer) => { + // Why: RELAY_SENTINEL must be written AFTER the handshake passes; if it + // were written earlier, waitForSentinel on the client would resolve + // and start sending JSON-RPC over a socket the daemon was about to + // close on mismatch — surfacing as a generic channel drop and + // re-entering the backoff loop. Sequencing it post-handshake makes + // mismatch a clean exit-42 path with no false-positive sentinel. + process.stdout.write(RELAY_SENTINEL) + // Why: bytes that arrived in the same TCP send as the handshake-ok + // frame were buffered inside the handshake's FrameDecoder. Forward + // them to stdout BEFORE attaching sock.pipe(process.stdout), so the + // multiplexer downstream sees them in order and no daemon frames + // are silently dropped at the transition. + if (leftover.length > 0) { + process.stdout.write(leftover) + } + process.stdin.pipe(sock) + sock.pipe(process.stdout) + } + }) }) // Why: when the SSH channel closes, stdout becomes a broken pipe. @@ -152,6 +168,13 @@ function main(): void { const context = new RelayContext() + // Why: session.registerRoot is now a protocol-level no-op (the relay no + // longer enforces a workspace allowlist; see docs/relay-fs-allowlist-removal.md). + // Both notification and request handlers are retained so a new main + // connecting to a new relay during the upgrade window — and an old main + // connecting to a new relay — both keep working without "Method not found" + // errors. Tracked for removal once the relay-version floor moves past the + // cutover. dispatcher.onNotification('session.registerRoot', (params) => { const rootPath = params.rootPath as string if (rootPath) { @@ -159,12 +182,6 @@ function main(): void { } }) - // Why: worktree creation needs to await root registration before sending - // addWorktree, which validates the target directory. While FIFO frame - // processing means a notification won't be reordered in steady state, - // the request variant makes the ordering guarantee explicit and closes - // failure windows during relay reconnect or fresh-host scenarios where - // roots may not yet be registered at all. See issue #911. dispatcher.onRequest('session.registerRoot', async (params) => { const rootPath = params.rootPath as string if (rootPath) { @@ -209,77 +226,81 @@ function main(): void { let activeSocket: Socket | null = null let socketServer: Server | null = null + const launchVersion = readLaunchVersion() + + function attachAcceptedSocket(sock: Socket, leftover: Buffer): void { + // Why: only one client at a time. If a second reconnect arrives (e.g. + // user restarts again quickly), close the stale bridge so the new one + // takes over cleanly. We null activeSocket BEFORE destroying so the old + // socket's close handler sees it's been replaced and skips starting the + // grace timer. + if (activeSocket) { + process.stderr.write('[relay] Replacing existing socket client with new connection\n') + const replaced = activeSocket + activeSocket = null + replaced.destroy() + } + activeSocket = sock + + // Why: stdin's data listener is still registered from the initial + // connection. If the old SSH channel hasn't fully closed yet (TCP FIN + // delayed), buffered stdin data would interleave with the new socket + // client's frames, corrupting the frame decoder. + process.stdin.pause() + process.stdin.removeAllListeners('data') + + ptyHandler.cancelGraceTimer() + + dispatcher.setWrite((data) => { + if (!sock.destroyed) { + sock.write(data) + } + }) + + // Why: bytes that arrived in the same TCP send as the handshake frame + // were buffered inside the handshake's FrameDecoder. Feed them into the + // dispatcher BEFORE wiring sock.on('data'), so frame ordering is + // preserved and no client data is silently dropped at the transition. + if (leftover.length > 0) { + dispatcher.feed(leftover) + } + + sock.on('data', (chunk: Buffer) => { + if (activeSocket !== sock) { + return + } + ptyHandler.cancelGraceTimer() + dispatcher.feed(chunk) + }) + } function startSocketServer(): Server { cleanupSocket(sockPath) const server = createServer((sock) => { - // Why: only one client at a time. If a second reconnect arrives - // (e.g. user restarts again quickly), close the stale bridge so the - // new one takes over cleanly. We null activeSocket BEFORE destroying - // so the old socket's close handler sees it's been replaced and - // skips starting the grace timer. - if (activeSocket) { - process.stderr.write('[relay] Replacing existing socket client with new connection\n') - const replaced = activeSocket - activeSocket = null - replaced.destroy() - } - activeSocket = sock + // Why: pre-dispatcher version handshake — see relay-handshake.ts. + setupDaemonHandshake(sock, { launchVersion, onAccepted: attachAcceptedSocket }) - // Why: stdin's data listener is still registered from the initial - // connection. If the old SSH channel hasn't fully closed yet (TCP - // FIN delayed), buffered stdin data would interleave with the new - // socket client's frames, corrupting the frame decoder. - process.stdin.pause() - process.stdin.removeAllListeners('data') - - ptyHandler.cancelGraceTimer() - - dispatcher.setWrite((data) => { - if (!sock.destroyed) { - sock.write(data) - } - }) - - sock.on('data', (chunk: Buffer) => { - if (activeSocket !== sock) { - return - } - ptyHandler.cancelGraceTimer() - dispatcher.feed(chunk) - }) - - // Why: when the --connect bridge's SSH channel dies, stdin.pipe(sock) - // calls sock.end(), sending FIN to the relay. Without this handler - // the relay-side socket stays half-open — the relay keeps writing - // pty.data frames that the bridge can no longer forward, silently - // dropping output until the next --connect replaces the socket. - // Destroying on 'end' ensures the 'close' handler fires promptly. + // Why: when --connect's SSH channel dies, stdin.pipe(sock) calls + // sock.end(), sending FIN to the relay. Destroying on 'end' ensures + // the 'close' handler fires promptly so the daemon can enter grace. sock.on('end', () => { if (!sock.destroyed) { sock.destroy() } }) + sock.on('error', () => { + // Why: Node emits 'error' then 'close'. The close handler owns + // activeSocket cleanup and grace startup. + }) + sock.on('close', () => { - // Why: only start the grace timer if THIS socket is still the - // active one. If it was replaced by a newer connection (see - // above), activeSocket was already nulled and reassigned — starting - // the grace timer here would incorrectly begin shutdown while a - // live client is connected. if (activeSocket === sock) { activeSocket = null dispatcher.invalidateClient() startGrace() } }) - - sock.on('error', () => { - // Why: Node emits 'error' then 'close'. The close handler owns - // activeSocket cleanup and grace startup; clearing activeSocket here - // would make close skip the grace timer and leave the relay alive - // indefinitely with no client. - }) }) // Why: setting umask to 0o177 BEFORE listen ensures the socket is diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 97fbc30f217..3f86841e6e4 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -62,7 +62,6 @@ describe('Subprocess: Relay entry point', () => { relay = spawn() await relay.sentinelReceived - relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) const id = relay.send('fs.stat', { filePath: path.join(tmpDir, 'test.txt') }) const resp = await relay.waitForResponse(id) @@ -80,7 +79,6 @@ describe('Subprocess: Relay entry point', () => { relay = spawn() await relay.sentinelReceived - relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) const id = relay.send('fs.readDir', { dirPath: tmpDir }) const resp = await relay.waitForResponse(id) @@ -95,7 +93,6 @@ describe('Subprocess: Relay entry point', () => { relay = spawn() await relay.sentinelReceived - relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) const filePath = path.join(tmpDir, 'output.txt') const wId = relay.send('fs.writeFile', { filePath, content: 'via subprocess' }) @@ -121,7 +118,6 @@ describe('Subprocess: Relay entry point', () => { relay = spawn() await relay.sentinelReceived - relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) const id = relay.send('git.status', { worktreePath: tmpDir }) const resp = await relay.waitForResponse(id) @@ -148,7 +144,6 @@ describe('Subprocess: Relay entry point', () => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) relay = spawn() await relay.sentinelReceived - relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) const id = relay.send('fs.readFile', { filePath: path.join(tmpDir, 'nonexistent.txt') }) const resp = await relay.waitForResponse(id) @@ -164,7 +159,6 @@ describe('Subprocess: Relay entry point', () => { relay = spawn() await relay.sentinelReceived - relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) const id1 = relay.send('fs.stat', { filePath: path.join(tmpDir, 'one.txt') }) const id2 = relay.send('fs.stat', { filePath: path.join(tmpDir, 'two.txt') }) @@ -202,53 +196,60 @@ describe('Subprocess: Relay entry point', () => { expect(relay.proc.exitCode).toBe(0) }, 10_000) - it('registers root via request and returns acknowledgment', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - writeFileSync(path.join(tmpDir, 'test.txt'), 'hello') - + it('session.registerRoot request returns ok acknowledgment', async () => { + // Why: session.registerRoot is a protocol-level no-op since the FS + // allowlist removal, but the request form still must reply { ok: true } + // for back-compat with mains during the upgrade window. See + // docs/relay-fs-allowlist-removal.md. relay = spawn() await relay.sentinelReceived - const id = relay.send('session.registerRoot', { rootPath: tmpDir }) + const id = relay.send('session.registerRoot', { rootPath: '/tmp/anything' }) const resp = await relay.waitForResponse(id) expect(resp.error).toBeUndefined() expect(resp.result).toEqual({ ok: true }) - - const statId = relay.send('fs.stat', { filePath: path.join(tmpDir, 'test.txt') }) - const statResp = await relay.waitForResponse(statId) - expect(statResp.error).toBeUndefined() - expect((statResp.result as { type: string }).type).toBe('file') }, 10_000) - it('rejects fs operations when no roots are registered', async () => { - tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) - writeFileSync(path.join(tmpDir, 'test.txt'), 'hello') - - relay = spawn() - await relay.sentinelReceived - - const id = relay.send('fs.stat', { filePath: path.join(tmpDir, 'test.txt') }) - const resp = await relay.waitForResponse(id) - - expect(resp.error).toBeDefined() - expect(resp.error!.message).toContain('No workspace roots registered yet') - }, 10_000) - - it('rejects paths outside registered roots', async () => { + it('reads files outside any registered root', async () => { + // Regression test for the architecture change in docs/relay-fs-allowlist-removal.md: + // the relay no longer enforces a workspace allowlist. tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-outside-')) - writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret') + writeFileSync(path.join(outsideDir, 'secret.txt'), 'visible') relay = spawn() await relay.sentinelReceived - relay.sendNotification('session.registerRoot', { rootPath: tmpDir }) - const id = relay.send('fs.stat', { filePath: path.join(outsideDir, 'secret.txt') }) + const id = relay.send('fs.readFile', { filePath: path.join(outsideDir, 'secret.txt') }) const resp = await relay.waitForResponse(id) - expect(resp.error).toBeDefined() - expect(resp.error!.message).toContain('Path outside authorized workspace') + expect(resp.error).toBeUndefined() + expect((resp.result as { content: string }).content).toBe('visible') + + await rm(outsideDir, { recursive: true, force: true }).catch(() => {}) + }, 10_000) + + it('reads files via symlinks resolving outside the workspace', async () => { + // Regression test for issue #1661: a symlink under the workspace pointing + // to a directory outside it must resolve transparently. The pre-removal + // relay rejected this with "Path outside authorized workspace". + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) + const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-outside-')) + writeFileSync(path.join(outsideDir, 'data.txt'), 'symlinked-target') + const { symlinkSync } = require('fs') + symlinkSync(outsideDir, path.join(tmpDir, 'link')) + + relay = spawn() + await relay.sentinelReceived + + const id = relay.send('fs.readFile', { + filePath: path.join(tmpDir, 'link', 'data.txt') + }) + const resp = await relay.waitForResponse(id) + + expect(resp.error).toBeUndefined() + expect((resp.result as { content: string }).content).toBe('symlinked-target') await rm(outsideDir, { recursive: true, force: true }).catch(() => {}) }, 10_000) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 27a2b2a5e71..8195a1eb170 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,33 +1,47 @@ /* eslint-disable max-lines */ -import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants' +import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { getDefaultUIState } from '../../shared/constants' -import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react' import { - FOCUS_TERMINAL_PANE_EVENT, - SYNC_FIT_PANES_EVENT, - TOGGLE_TERMINAL_PANE_EXPAND_EVENT -} from '@/constants/terminal' + ArrowLeft, + ArrowRight, + Minimize2, + MoreHorizontal, + PanelLeft, + PanelRight +} from 'lucide-react' +import logo from '../../../resources/logo.svg' +import { SYNC_FIT_PANES_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' import { syncZoomCSSVar } from '@/lib/ui-zoom' -import { toast } from 'sonner' +import { buildAppFontFamily } from '@/lib/app-font-family' import { Toaster } from '@/components/ui/sonner' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' import { useAppStore } from './store' import { useShallow } from 'zustand/react/shallow' import { useIpcEvents } from './hooks/useIpcEvents' import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate' +import { ActivityTitlebarControls } from './components/activity/ActivityTitlebarControls' import Sidebar from './components/Sidebar' import Terminal from './components/Terminal' -import { shutdownBufferCaptures } from './components/terminal-pane/TerminalPane' +import { shutdownBufferCaptures } from './components/terminal-pane/shutdown-buffer-captures' import RightSidebar from './components/right-sidebar' import { StatusBar } from './components/status-bar/StatusBar' import { UpdateCard } from './components/UpdateCard' import { StarNagCard } from './components/StarNagCard' +import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' +import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' +import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { setRuntimeGraphStoreStateGetter, setRuntimeGraphSyncEnabled @@ -36,50 +50,101 @@ import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' import { buildWorkspaceSessionPayload } from './lib/workspace-session' import { createSessionWriteSubscriber } from './lib/session-write-subscriber' -import { countWorkingAgents, getWorkingAgentsPerWorktree } from './lib/agent-status' -import { activateAndRevealWorktree } from './lib/worktree-activation' -import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' -import { findWorktreeById, getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { applyDocumentTheme } from './lib/document-theme' +import { isEditableTarget } from './lib/editable-target' import { canGoBackWorktreeHistory, canGoForwardWorktreeHistory } from '@/store/slices/worktree-nav-history' -import { dispatchClearModifierHints } from './hooks/useModifierHint' +import type { OnboardingState } from '../../shared/types' const isMac = navigator.userAgent.includes('Mac') +const isWindows = !isMac && navigator.userAgent.includes('Windows') + +// Why: 'hidden' titleBarStyle on Windows removes the native OS title bar, +// so we render our own minimize/maximize/close buttons. These SVG icons match +// the Fluent/Win11 style: thin 10×10 paths on a 40×30 hit area. +function WindowControls(): React.JSX.Element { + const [maximized, setMaximized] = useState(false) + useEffect(() => { + // Why: window:maximize-changed only fires on transitions, so a window + // restored to a maximized state at startup would render the wrong icon + // until the user first clicks the button. Seed from main on mount. + let cancelled = false + void window.api.ui.isMaximized().then((value) => { + if (!cancelled) { + setMaximized(value) + } + }) + const unsubscribe = window.api.ui.onMaximizeChanged(setMaximized) + return () => { + cancelled = true + unsubscribe() + } + }, []) + return ( +
+ + + +
+ ) +} + const Landing = lazy(() => import('./components/Landing')) const TaskPage = lazy(() => import('./components/TaskPage')) +const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage')) const Settings = lazy(() => import('./components/settings/Settings')) const QuickOpen = lazy(() => import('./components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal')) // Why: lazy-loaded so the WebP asset + overlay module aren't fetched unless // the user opts into the experimental flag. -const SidekickOverlay = lazy(() => import('./components/sidekick/SidekickOverlay')) - -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false - } - - // xterm.js focuses a hidden