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/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..4352af6e065 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,285 @@ 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 + + - 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/.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/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/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-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..c3c531c35f3 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   diff --git a/docs/README.ja.md b/docs/README.ja.md index d92b0c2f3ed..2fbf2d0387a 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   diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 163b1bfb64e..1f390e29b88 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   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/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/package.json b/package.json index 0dd8f905fb7..dbe4c7fc479 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.33-rc.1", + "version": "1.3.48-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -49,39 +49,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 +89,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 +103,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 +168,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/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.ts b/src/cli/args.ts index f61cfb751a1..45413a580f1 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -64,7 +64,14 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { if (['repo', 'worktree', 'terminal'].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 { 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..6dd41e3a554 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -7,6 +7,7 @@ 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' @@ -32,6 +33,7 @@ function buildHandlers(): Map { BROWSER_NAV_HANDLERS, BROWSER_INTERACT_HANDLERS, BROWSER_TAB_HANDLERS, + BROWSER_PROFILE_HANDLERS, BROWSER_COOKIE_HANDLERS, BROWSER_CAPTURE_HANDLERS, BROWSER_ENV_HANDLERS, diff --git a/src/cli/format.ts b/src/cli/format.ts index b1d1ac43269..2ea429e13a1 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,7 +1,12 @@ import type { + BrowserProfileListResult, + BrowserTabCurrentResult, BrowserScreenshotResult, BrowserSnapshotResult, BrowserTabListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabShowResult, CliStatusResult, RuntimeRepoList, RuntimeRepoSearchRefs, @@ -237,13 +242,59 @@ 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'})` +} 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/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/help.ts b/src/cli/help.ts index 2e8f6ddb9f5..3adb9cc9810 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -59,6 +59,15 @@ Orchestration: 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) @@ -183,6 +192,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 +213,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" @@ -306,6 +320,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..675c1105932 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -197,6 +197,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/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/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..b845fdf3c17 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' @@ -77,6 +78,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', + connectionId: null, payload: expect.objectContaining({ state: 'working', prompt: 'replay me', @@ -151,6 +153,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'repo::/tmp/worktree with "quotes"', + connectionId: null, payload: expect.objectContaining({ state: 'working', prompt: 'form encoded', @@ -660,6 +663,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 +859,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 +1205,145 @@ describe('Endpoint file lifecycle', () => { } }) }) + +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({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + 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..9dff04237c9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,1077 +1,36 @@ -/* 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. */ +// 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) 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 { 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 { 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' - -type AgentHookEventPayload = { - paneKey: string - tabId?: string - worktreeId?: string - payload: ParsedAgentStatusPayload -} - -// 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: 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: 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 - } - } - return '' -} - -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( - 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) - } - if (promptText) { - lastPromptByPaneKey.set(paneKey, promptText) - return promptText - } - 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 previous = lastToolByPaneKey.get(paneKey) ?? {} - const merged: ToolSnapshot = { - toolName: update.toolName ?? previous.toolName, - toolInput: update.toolInput ?? previous.toolInput, - lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage - } - 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 - } - if (typeof toolInput !== 'object' || toolInput === null) { - return undefined - } - if (!toolName) { - return undefined - } - 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 = {} - if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' - ) { - 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) { - 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) { - 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( - 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 - }) - ) -} - -// 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 - } - - 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 type { AgentHookSource } export class AgentHookServer { private server: ReturnType | null = null @@ -1084,19 +43,14 @@ export class AgentHookServer { private onAgentStatus: ((payload: AgentHookEventPayload) => 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() setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void { this.onAgentStatus = listener @@ -1105,7 +59,7 @@ export class AgentHookServer { } // 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) } catch (err) { @@ -1114,6 +68,86 @@ export class AgentHookServer { } } + /** 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 + } + this.state.lastStatusByPaneKey.set(paneKey, event) + this.onAgentStatus?.(event) + } + async start(options?: { env?: string; userDataPath?: string }): Promise { if (this.server) { return @@ -1144,37 +178,23 @@ 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) + const payload = normalizeHookPayload(this.state, source, body, this.env) if (payload) { - lastStatusByPaneKey.set(payload.paneKey, payload) + this.state.lastStatusByPaneKey.set(payload.paneKey, payload) this.onAgentStatus?.(payload) } @@ -1189,12 +209,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 +225,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) @@ -1228,36 +241,16 @@ 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() + clearAllListenerCaches(this.state) } 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) + clearPaneCacheState(this.state, paneKey) } buildPtyEnv(): Record { @@ -1265,12 +258,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,154 +270,49 @@ 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 { + 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 lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] - 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. - } - } - // 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 }) - tmpWritten = true - renameSync(tmpPath, finalPath) - this.endpointFileWritten = true - } 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. - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // Why: tmp may already be gone (rename partially succeeded, or an - // external process cleaned it). Nothing to do. - } - } - } + const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION + }) + this.endpointFileWritten = ok } } 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..c678e5390e2 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,15 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, - experimentalAgentDashboard: false, - experimentalSidekick: false, + experimentalMobile: false, + mobileAutoRestoreFitMs: null, + experimentalPet: false, + experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, - ...overrides + ...overrides, + appFontFamily } } @@ -127,6 +133,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 +189,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 +231,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 +243,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 +300,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 +418,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 +482,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 +503,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 +528,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 +598,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 +712,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 +971,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 +1084,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 +1200,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 +1466,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 +1502,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..e54e37c0ca9 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,15 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, - experimentalAgentDashboard: false, - experimentalSidekick: false, + experimentalMobile: false, + mobileAutoRestoreFitMs: null, + experimentalPet: 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/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 e62d22c70ef..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']) }) }) @@ -436,7 +455,7 @@ describe('addSparseWorktree', () => { '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 @@ -446,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 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.ts b/src/main/git/worktree.ts index 83c76a5088b..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 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..fa075da9d55 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,44 +266,38 @@ 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 }) => { + agentHookServer.setListener(({ paneKey, tabId, worktreeId, connectionId, 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) { - mainWindow?.webContents.send('agentStatus:set', { - paneKey, - tabId, - worktreeId, - ...payload - }) - } - // 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) + mainWindow?.webContents.send('agentStatus:set', { + ...payload, + paneKey, + tabId, + worktreeId, + connectionId + }) + // 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) } }) return window @@ -286,36 +310,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 +380,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 +442,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 +481,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 +544,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 +578,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 +611,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 +654,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-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/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..d7c2107a72b 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,79 @@ 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('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 +417,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 +639,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..0309b1d64f6 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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' @@ -102,7 +105,6 @@ async function isBinaryFilePrefix(filePath: string): Promise { } export function registerFilesystemHandlers(store: Store): void { - void rebuildAuthorizedRootsCache(store) const activeTextSearches = new Map() // ─── Filesystem ───────────────────────────────────────── @@ -505,6 +507,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 +547,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..8651d7d6222 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -21,7 +21,9 @@ const { buildAgentHookEnvMock, piBuildPtyEnvMock, piClearPtyMock, - isPwshAvailableMock + isPwshAvailableMock, + trackMock, + classifyErrorMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -40,7 +42,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', () => ({ @@ -95,6 +99,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 +115,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() } } @@ -142,6 +158,8 @@ describe('registerPtyHandlers', () => { piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() + trackMock.mockReset() + classifyErrorMock.mockReset() mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() @@ -151,12 +169,14 @@ describe('registerPtyHandlers', () => { getPathMock.mockReturnValue('/tmp/orca-user-data') existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 }) - openCodeBuildPtyEnvMock.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' @@ -336,12 +356,14 @@ 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('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') }) it('injects the Claude/Codex hook receiver env into Orca terminal PTYs', async () => { @@ -499,9 +521,16 @@ 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') }) it('injects Pi overlay env (PI_CODING_AGENT_DIR) on the daemon path', async () => { @@ -511,6 +540,7 @@ 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') }) it('injects the selected Codex home on the daemon path', async () => { @@ -608,6 +638,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 +793,9 @@ 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.PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.CODEX_HOME).toBeUndefined() expect(env.FOO).toBe('bar') expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() @@ -812,7 +845,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 () => { @@ -914,11 +950,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 +963,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 +1022,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 +1044,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 +1067,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 +1090,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 +1113,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 +1263,78 @@ 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.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 +1479,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 +1526,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 +1721,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..cbbe63a4de0 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,14 @@ 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' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -36,6 +47,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 +80,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 @@ -132,16 +185,18 @@ export function buildPtyHostEnv( baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR const preexistingPiAgentDir = baseEnv.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR - 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 } - 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 +213,11 @@ 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 + } // Why: Codex account switching now materializes auth into one shared // runtime home (~/.codex), and Codex launched inside Orca terminals must @@ -284,10 +344,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 +359,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 +426,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 +437,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 @@ -436,10 +514,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 +534,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 +564,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 @@ -523,7 +689,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 +701,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 +750,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 +906,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 @@ -704,25 +925,134 @@ export function registerPtyHandlers( try { result = await provider.spawn(spawnOptions) } catch (err) { + const rawMessage = err instanceof Error ? err.message : String(err) + const hintedMessage = addNodePtyRecoveryHint(rawMessage) + let spawnError: Error + if (hintedMessage === rawMessage && err instanceof Error) { + spawnError = err + } else if (err instanceof Error) { + // Why: rewrite the message in place so the original stack trace, + // name, and any custom fields survive into telemetry and logs. + err.message = hintedMessage + spawnError = err + } else { + spawnError = new Error(hintedMessage) + } + 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 } 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 +1106,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 +1151,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 +1207,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 +1286,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..40840246e0e 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -14,13 +14,14 @@ const { registerSettingsHandlersMock, registerTelemetryHandlersMock, registerShellHandlersMock, - registerSidekickHandlersMock, + registerPetHandlersMock, registerSessionHandlersMock, registerUIHandlersMock, registerFilesystemHandlersMock, registerRuntimeHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, + registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerClipboardHandlersMock, registerUpdaterHandlersMock, @@ -31,7 +32,8 @@ const { registerFilesystemWatcherHandlersMock, registerAppHandlersMock, registerLinearHandlersMock, - registerExportHandlersMock + registerExportHandlersMock, + registerOnboardingHandlersMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), @@ -46,13 +48,14 @@ const { 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 +66,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', () => ({ @@ -122,8 +130,8 @@ vi.mock('./shell', () => ({ registerShellHandlers: registerShellHandlersMock })) -vi.mock('./sidekick', () => ({ - registerSidekickHandlers: registerSidekickHandlersMock +vi.mock('./pet', () => ({ + registerPetHandlers: registerPetHandlersMock })) vi.mock('./session', () => ({ @@ -158,6 +166,10 @@ vi.mock('./agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) +vi.mock('./agent-trust', () => ({ + registerAgentTrustHandlers: registerAgentTrustHandlersMock +})) + vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) @@ -198,13 +210,14 @@ describe('registerCoreHandlers', () => { 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 +256,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 +264,10 @@ 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(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..b69896f3b52 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -17,6 +17,7 @@ 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 { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' import { registerSessionHandlers } from './session' @@ -25,10 +26,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 +74,7 @@ export function registerCoreHandlers( registerCodexUsageHandlers(codexUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers() + registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) @@ -80,10 +83,11 @@ export function registerCoreHandlers( registerExportHandlers() registerStatsHandlers(stats) registerMemoryHandlers(store) - registerNotificationHandlers(store) + registerNotificationHandlers(store, runtime) + registerOnboardingHandlers(store) registerDeveloperPermissionHandlers() registerSettingsHandlers(store) - registerTelemetryHandlers() + registerTelemetryHandlers(store) registerBrowserHandlers() // Why: applyPendingCookieImport MUST run before restorePersistedUserAgent // because the latter calls session.fromPartition() which initializes @@ -92,7 +96,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..5c9074b5319 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, @@ -51,6 +75,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 +98,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 +112,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 +161,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 +222,204 @@ 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:remove', async (_event, args: { repoId: string }) => { store.removeRepo(args.repoId) - await rebuildAuthorizedRootsCache(store) + invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) }) @@ -218,6 +437,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'hookSettings' | 'worktreeBaseRef' | 'kind' + | 'symlinkPaths' | 'issueSourcePreference' > > @@ -239,6 +459,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 +579,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 +656,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 +675,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..edb556697b1 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -162,6 +162,7 @@ export function registerSshHandlers( 'ssh:connect', 'ssh:disconnect', 'ssh:getState', + 'ssh:needsPassphrasePrompt', 'ssh:testConnection', 'ssh:addPortForward', 'ssh:updatePortForward', @@ -424,6 +425,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 93eff449883..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 }), @@ -170,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 @@ -192,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 } } @@ -209,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 @@ -302,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) } @@ -352,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( @@ -388,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, @@ -413,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) { @@ -449,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..1a9cab0b38b 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 @@ -594,6 +772,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 +822,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-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 3f714573d1d..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, 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 7a88ec5a0d3..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, 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/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..c1a098ae3de 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() @@ -471,32 +494,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 () => { @@ -751,7 +786,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 +803,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 +993,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) @@ -989,7 +1071,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) @@ -1029,6 +1114,54 @@ describe('OrcaRuntimeService', () => { 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 +1200,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 +1253,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 +1314,7 @@ describe('OrcaRuntimeService', () => { return nextMeta }, removeWorktreeMeta: () => {}, + getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: 'C:\\workspaces', nestWorkspaces: false, @@ -1256,6 +1433,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 +1642,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..e73d9855e8d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7,15 +7,27 @@ 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 { + DESKTOP_PROTOCOL_VERSION, + MIN_COMPATIBLE_MOBILE_VERSION +} from '../../shared/protocol-version' import type { RuntimeGraphStatus, RuntimeRepoSearchRefs, @@ -32,6 +44,7 @@ import type { RuntimeTerminalWait, RuntimeTerminalWaitCondition, RuntimeWorktreePsSummary, + RuntimeWorktreeStatus, RuntimeTerminalShow, RuntimeTerminalSummary, RuntimeSyncedLeaf, @@ -47,9 +60,17 @@ import type { BrowserScrollResult, BrowserBackResult, BrowserReloadResult, + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileListResult, BrowserScreenshotResult, BrowserEvalResult, + BrowserTabCurrentResult, BrowserTabListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabSetProfileResult, + BrowserTabShowResult, BrowserTabSwitchResult, BrowserHoverResult, BrowserDragResult, @@ -75,7 +96,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 +107,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 +137,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 +177,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,18 +205,59 @@ 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 + lastOutputAt: number | null + tailBuffer: string[] + tailPartialLine: string + tailTruncated: boolean + tailLinesTotal: number + preview: string +} + +type RuntimeHeadlessTerminal = { + emulator: HeadlessEmulator + writeChain: Promise } type RuntimePtyController = { 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 splitTerminal( tabId: string, @@ -159,6 +267,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 +342,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 +418,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 +644,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 +660,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 +695,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 +765,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 +855,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 +868,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 +893,27 @@ 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) + } + 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 +924,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 +952,772 @@ 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 + } for (const leaf of this.leaves.values()) { if (leaf.ptyId !== ptyId) { @@ -514,6 +1732,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 +2804,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 +2888,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 +2908,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 +2960,26 @@ 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') + } + const wrote = this.ptyController?.write(pty.pty.ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + 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') @@ -820,36 +3119,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 +3192,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 +3321,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 +3355,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 +3389,8 @@ export class OrcaRuntimeService { linkedIssue?: number | null comment?: string runHooks?: boolean + setupDecision?: 'run' | 'skip' | 'inherit' + startup?: WorktreeStartupLaunch }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -1022,10 +3445,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 +3473,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 +3496,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 @@ -1090,7 +3534,11 @@ export class OrcaRuntimeService { // 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) + if (args.startup) { + this.notifier?.activateWorktree(repo.id, worktree.id, setup, args.startup) + } else { + 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.) @@ -1105,12 +3553,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 +3902,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 +3983,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 +3995,7 @@ export class OrcaRuntimeService { throw new Error(formatWorktreeRemovalError(error, worktree.path, force)) } + this.clearOptimisticReconcileToken(worktree.id) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() @@ -1313,6 +4098,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 +4171,10 @@ export class OrcaRuntimeService { async focusTerminal(handle: string): Promise { this.assertGraphReady() + const pty = this.getLivePtyForHandle(handle) + if (pty) { + return { handle, 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 } @@ -1471,6 +4308,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 +4340,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 +4482,108 @@ 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, + 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 +4678,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 +4690,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 +4743,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: null, + connected: pty.connected, + writable: pty.connected, + lastOutputAt: pty.lastOutputAt, + preview: pty.preview + } + } + private getLiveLeafForHandle(handle: string): { record: TerminalHandleRecord leaf: RuntimeLeafRecord @@ -1798,6 +4784,53 @@ export class OrcaRuntimeService { return { record, leaf } } + private getLivePtyForHandle(handle: string): { + record: TerminalHandleRecord + pty: RuntimePtyWorktreeRecord + } | null { + const 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 + } + 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' : '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 +4887,35 @@ export class OrcaRuntimeService { return preAllocated } + private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = this.handleByPtyId.get(pty.ptyId) + if (existingHandle) { + const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.runtimeId === this.runtimeId && + existingRecord.ptyId === pty.ptyId + ) { + return existingHandle + } + } + + const handle = `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 refreshWritableFlags(): void { for (const leaf of this.leaves.values()) { leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null @@ -2002,8 +5064,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 +5081,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 +5238,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 +5400,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 +6078,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 +6129,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 +6358,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 +6452,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 +6568,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, @@ -3256,6 +6602,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 +6717,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/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/index.ts b/src/main/runtime/rpc/methods/index.ts index 9bdcc514c26..bce5a50c4e0 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,22 @@ 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' // 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 ] 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..5f905fd82da 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({ @@ -69,7 +90,8 @@ 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 +99,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 +233,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', @@ -157,6 +287,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 +323,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..b9f507c1293 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -28,20 +28,28 @@ 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, + 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 +75,29 @@ 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, + setupDecision: params.setupDecision, + startup: params.startupCommand ? { command: params.startupCommand } : undefined }) }), defineMethod({ @@ -87,7 +107,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..eaccbaed070 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 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/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 85350f2586b..b547d5f51eb 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -179,6 +179,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..8d1a963ee4f 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -201,7 +201,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 +218,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 { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 6c986fbcb48..fc19c622b1c 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -55,6 +55,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 +120,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 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-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 008b758e173..fd66ecb1f4f 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -1,7 +1,7 @@ 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' function createMockChannel(): ClientChannel { @@ -39,4 +39,39 @@ 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) + }) +}) + +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..74773117170 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -10,12 +10,14 @@ 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 const timeout = setTimeout(() => { - if (!sentinelReceived) { + if (!settled) { + settled = true channel.close() reject( new Error( @@ -36,20 +38,48 @@ 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 => { + clearTimeout(timeout) + 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) { + clearTimeout(timeout) + if (!settled) { + settled = true + reject( + new Error( + `Relay process exited before ready.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` + ) + ) + } + return + } + notifyClosed() }) // Why: data arriving in the same TCP chunk as the sentinel is buffered @@ -88,6 +118,7 @@ export function waitForSentinel(channel: ClientChannel): Promise 0) { pendingAfterSentinel = afterSentinel } + settled = true const transport: MultiplexerTransport = { write: (buf: Buffer) => channel.stdin.write(buf), @@ -139,6 +170,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') }) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index fe51892f138..8251b244c04 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -345,7 +345,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-session.ts b/src/main/ssh/ssh-relay-session.ts index 2a972db6af1..f0f8cc2b091 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -390,9 +390,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/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.ts b/src/main/updater.ts index 72048271cee..d99f6521222 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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..458f03bd277 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -4,7 +4,7 @@ import path from 'node:path' 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 +23,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 +41,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 +51,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 +203,47 @@ 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 }), + 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..80582f4c2e7 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, @@ -102,6 +140,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 +157,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 +192,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 +252,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 +361,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 +372,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 = { @@ -365,6 +414,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 +451,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 +470,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 +493,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 +504,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 +532,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 +608,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 +627,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 +731,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,10 +779,27 @@ 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 @@ -629,13 +814,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 +946,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 +963,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 +986,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 +1031,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,11 +1059,17 @@ 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 @@ -888,6 +1103,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 +1122,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 +1177,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 }> @@ -992,6 +1236,11 @@ export type PreloadApi = { paneKey: string tabId?: string worktreeId?: string + // Why: stamped by main from the SshChannelMultiplexer the event + // arrived on (or null for local). The renderer uses it to drop + // in-flight events when an SSH connection tears down — see + // docs/design/agent-status-over-ssh.md §5. + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string @@ -1002,6 +1251,26 @@ export type PreloadApi = { }) => void ) => () => 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 }> + } } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index bb52c07c437..0250700d737 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,26 +4,66 @@ 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, @@ -31,6 +71,9 @@ import type { DetectedPort } from '../shared/ssh-types' import type { AgentStatusState } 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,6 +279,12 @@ 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), update: (args: { repoId: string; updates: Record }): Promise => @@ -308,6 +381,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 +413,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 +445,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 +462,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 +502,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 +553,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 +575,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 +590,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 +678,7 @@ const api = { repoPath: string number: number body: string + type?: 'issue' | 'pr' }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { @@ -541,8 +707,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 +863,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 +928,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 +942,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 +951,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: { @@ -732,6 +1049,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 +1058,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 +1072,7 @@ const api = { browserPageId: string workspaceId: string worktreeId: string + sessionProfileId?: string | null webContentsId: number }): Promise => ipcRenderer.invoke('browser:registerGuest', args), @@ -932,6 +1253,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 +1597,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 +1617,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 +1683,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 +1707,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 +1728,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 +1789,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 +1814,7 @@ const api = { repoId: string worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } + startup?: { command: string; env?: Record } }) => void ): (() => void) => { const listener = ( @@ -1454,6 +1823,7 @@ const api = { repoId: string worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } + startup?: { command: string; env?: Record } } ) => callback(data) ipcRenderer.on('ui:activateWorktree', listener) @@ -1542,6 +1912,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 +1962,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 +2076,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 +2156,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,6 +2266,36 @@ 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: ( @@ -1815,6 +2303,11 @@ const api = { paneKey: string tabId?: string worktreeId?: string + // Why: stamped by main from the SshChannelMultiplexer the event + // arrived on (or null for local). The renderer uses it to drop + // in-flight events when an SSH connection tears down — see + // docs/design/agent-status-over-ssh.md §5. + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string @@ -1830,6 +2323,7 @@ const api = { paneKey: string tabId?: string worktreeId?: string + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string 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.test.ts b/src/relay/fs-handler.test.ts index aef0fea7ff8..9c068a07c0d 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) }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 413fb53f7fc..89070b8836f 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -2,6 +2,8 @@ import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from import { execFile } from 'child_process' 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, @@ -24,12 +26,10 @@ type WatchState = { export class FsHandler { private dispatcher: RelayDispatcher - private context: RelayContext private watches = new Map() - constructor(dispatcher: RelayDispatcher, context: RelayContext) { + constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher - this.context = context this.registerHandlers() } @@ -52,7 +52,6 @@ export class FsHandler { 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) => ({ @@ -70,13 +69,11 @@ export class FsHandler { private async readFile(params: Record) { const filePath = expandTilde(params.filePath as string) - await this.context.validatePathResolved(filePath) return readRelayFileContent(filePath) } 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,7 +90,6 @@ 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. @@ -109,7 +105,6 @@ export class FsHandler { 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 +115,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 +122,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 +179,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 +215,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') 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 e2474d82aed..174164b8bda 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -5,58 +5,28 @@ 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) }) @@ -67,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') @@ -75,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', () => { @@ -90,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 () => { @@ -139,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', () => { @@ -273,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', () => { @@ -455,50 +668,4 @@ 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') - - 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.ts b/src/relay/git-handler.ts index d769013a0f7..79de7175c82 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,28 +1,20 @@ -/* eslint-disable max-lines -- Why: this is the relay's single git RPC entry - point, registering and implementing handlers for ~14 git methods. Splitting - would scatter related handlers without a clean boundary; addWorktree's - --no-track + push.autoSetupRemote probe-and-write mirror state required to - stay in step with the local addWorktree path tipped this over the threshold. */ 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 @@ -30,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)) @@ -48,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)) @@ -78,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. @@ -162,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) @@ -186,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) @@ -196,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) @@ -215,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. @@ -243,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 "-"') @@ -274,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 { @@ -296,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) @@ -306,85 +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 - - // 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 this.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 this.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 this.git(['config', '--local', 'push.autoSetupRemote', 'true'], targetDir) - } - } catch (error) { - console.warn(`relay addWorktree: failed to set push.autoSetupRemote for ${targetDir}`, error) - } + 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..1bb3f9b30f7 100644 --- a/src/relay/integration.test.ts +++ b/src/relay/integration.test.ts @@ -75,7 +75,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) diff --git a/src/relay/relay.ts b/src/relay/relay.ts index aadf926d220..ca306d2c9ba 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -152,6 +152,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 +166,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) { 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 7e3db4e3a62..389dedbfd7f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,33 +1,46 @@ /* 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 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 @@ -35,17 +48,83 @@ import { import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' import { buildWorkspaceSessionPayload } from './lib/workspace-session' -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 Settings = lazy(() => import('./components/settings/Settings')) @@ -54,31 +133,15 @@ 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